This commit is contained in:
Dan Wahlin 2021-11-04 22:02:16 -07:00
Коммит 08f3f03d73
55 изменённых файлов: 46691 добавлений и 0 удалений

4
.env-template Normal file
Просмотреть файл

@ -0,0 +1,4 @@
REACT_APP_TENANT_ID=
REACT_APP_PRIMARY_KEY=
REACT_APP_ORDERER_ENDPOINT=
REACT_APP_STORAGE_ENDPOINT=

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

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.env
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
react-app-env.d.ts
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# work around for https://github.com/facebook/create-react-app/issues/6560
src/react-app-env.d.ts

5
.vscode/extensions.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,5 @@
{
"recommendations": [
"ms-azuretools.vscode-azurefunctions"
]
}

12
.vscode/launch.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Node Functions",
"type": "node",
"request": "attach",
"port": 9229,
"preLaunchTask": "func: host start"
}
]
}

8
.vscode/settings.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,8 @@
{
"azureFunctions.deploySubpath": "EventHubsFunctions",
"azureFunctions.postDeployTask": "npm install (functions)",
"azureFunctions.projectLanguage": "JavaScript",
"azureFunctions.projectRuntime": "~3",
"debug.internalConsoleOptions": "neverOpen",
"azureFunctions.preDeployTask": "npm prune (functions)"
}

32
.vscode/tasks.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,32 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "func",
"command": "host start",
"problemMatcher": "$func-node-watch",
"isBackground": true,
"dependsOn": "npm install (functions)",
"options": {
"cwd": "${workspaceFolder}/EventHubsFunctions"
}
},
{
"type": "shell",
"label": "npm install (functions)",
"command": "npm install",
"options": {
"cwd": "${workspaceFolder}/EventHubsFunctions"
}
},
{
"type": "shell",
"label": "npm prune (functions)",
"command": "npm prune --production",
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/EventHubsFunctions"
}
}
]
}

5
EventHubsFunctions/.vscode/extensions.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,5 @@
{
"recommendations": [
"ms-azuretools.vscode-azurefunctions"
]
}

12
EventHubsFunctions/.vscode/launch.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Node Functions",
"type": "node",
"request": "attach",
"port": 9229,
"preLaunchTask": "func: host start"
}
]
}

8
EventHubsFunctions/.vscode/settings.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,8 @@
{
"azureFunctions.deploySubpath": ".",
"azureFunctions.postDeployTask": "npm install (functions)",
"azureFunctions.projectLanguage": "JavaScript",
"azureFunctions.projectRuntime": "~3",
"debug.internalConsoleOptions": "neverOpen",
"azureFunctions.preDeployTask": "npm prune (functions)"
}

23
EventHubsFunctions/.vscode/tasks.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,23 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "func",
"command": "host start",
"problemMatcher": "$func-node-watch",
"isBackground": true,
"dependsOn": "npm install (functions)"
},
{
"type": "shell",
"label": "npm install (functions)",
"command": "npm install"
},
{
"type": "shell",
"label": "npm prune (functions)",
"command": "npm prune --production",
"problemMatcher": []
}
]
}

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

@ -0,0 +1,23 @@
{
"bindings": [
{
"type": "eventHubTrigger",
"name": "eventHubMessages",
"direction": "in",
"eventHubName": "event-hub",
"connection": "AzureEventHubConnectionString",
"cardinality": "many",
"consumerGroup": "local"
},
{
"name": "signalRMessages",
"hubName": "serverless",
"connectionStringSetting": "AzureSignalRConnectionString",
"direction": "out",
"type": "signalR",
"parameterNames": [
"message"
]
}
]
}

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

@ -0,0 +1,30 @@
module.exports = async function (context, eventHubMessages) {
context.log(`JavaScript eventhub trigger function called for message array ${eventHubMessages}`);
eventHubMessages.forEach((message, index) => {
context.log(`Processed message ${message}`);
context.log(JSON.stringify(message));
var body = JSON.stringify(message);
var jsonMessage = JSON.parse(body);
context.log(jsonMessage);
for (let i in jsonMessage.value) {
var resourceData = jsonMessage.value[i].resourceData;
context.log(resourceData);
context.bindings.signalRMessages = [{
"target": "newMessage",
"arguments": [`{
"id" : "${resourceData.id}",
"availability" : "${resourceData.availability}"
}`]
}];
}
});
};

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

@ -0,0 +1,15 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[2.*, 3.0.0)"
}
}

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

@ -0,0 +1,13 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
***REMOVED***
***REMOVED***
***REMOVED***
},
"Host": {
"CORS": "http://localhost:3000",
"CORSCredentials": true
}
}

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

@ -0,0 +1,27 @@
{
"disabled": false,
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"methods": [
"post"
],
"name": "req",
"route": "negotiate"
},
{
"type": "http",
"direction": "out",
"name": "res"
},
{
"type": "signalRConnectionInfo",
"name": "connectionInfo",
"hubName": "serverless",
"connectionStringSetting": "AzureSignalRConnectionString",
"direction": "in"
}
]
}

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

@ -0,0 +1,3 @@
module.exports = async function (context, req, connectionInfo) {
context.res.body = connectionInfo;
};

404
EventHubsFunctions/package-lock.json сгенерированный Normal file
Просмотреть файл

@ -0,0 +1,404 @@
{
"name": "EventHubFunctionsSignalR",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"@microsoft/signalr": "^5.0.9",
"react-toastify": "^8.0.2"
}
},
"node_modules/@microsoft/signalr": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.9.tgz",
"integrity": "sha512-pQufk3+mChfystnmYpglyRYQFp+036QmOxbZUFr2cFf2iiS8ekBX5uVBOG8OexKcsG4TcJNAU/ref90Y9+3ZiA==",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^1.0.7",
"fetch-cookie": "^0.7.3",
"node-fetch": "^2.6.0",
"ws": "^6.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
},
"node_modules/clsx": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==",
"engines": {
"node": ">=6"
}
},
"node_modules/es6-denodeify": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz",
"integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8="
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz",
"integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==",
"dependencies": {
"original": "^1.0.0"
},
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/fetch-cookie": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz",
"integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==",
"dependencies": {
"es6-denodeify": "^0.1.1",
"tough-cookie": "^2.3.3"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/original": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
"integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==",
"dependencies": {
"url-parse": "^1.4.3"
}
},
"node_modules/psl": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
},
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
"integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
"integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"scheduler": "^0.20.2"
},
"peerDependencies": {
"react": "17.0.2"
}
},
"node_modules/react-toastify": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.0.2.tgz",
"integrity": "sha512-0Nud2d0VD4LIevgkB4L8NYoQ5plTpfqgj2CRVxs58SGA/TTO+2Ojz4C1bLUdGUWsw0zuWqd4GJqxNuMIv0cXMw==",
"dependencies": {
"clsx": "^1.1.1"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"node_modules/scheduler": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
"integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/ws": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
"integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==",
"dependencies": {
"async-limiter": "~1.0.0"
}
}
},
"dependencies": {
"@microsoft/signalr": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.9.tgz",
"integrity": "sha512-pQufk3+mChfystnmYpglyRYQFp+036QmOxbZUFr2cFf2iiS8ekBX5uVBOG8OexKcsG4TcJNAU/ref90Y9+3ZiA==",
"requires": {
"abort-controller": "^3.0.0",
"eventsource": "^1.0.7",
"fetch-cookie": "^0.7.3",
"node-fetch": "^2.6.0",
"ws": "^6.0.0"
}
},
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
},
"clsx": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="
},
"es6-denodeify": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz",
"integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8="
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"eventsource": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz",
"integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==",
"requires": {
"original": "^1.0.0"
}
},
"fetch-cookie": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz",
"integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==",
"requires": {
"es6-denodeify": "^0.1.1",
"tough-cookie": "^2.3.3"
}
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"peer": true
},
"original": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
"integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==",
"requires": {
"url-parse": "^1.4.3"
}
},
"psl": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
"integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
"integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"scheduler": "^0.20.2"
}
},
"react-toastify": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.0.2.tgz",
"integrity": "sha512-0Nud2d0VD4LIevgkB4L8NYoQ5plTpfqgj2CRVxs58SGA/TTO+2Ojz4C1bLUdGUWsw0zuWqd4GJqxNuMIv0cXMw==",
"requires": {
"clsx": "^1.1.1"
}
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"scheduler": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
"integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"requires": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
}
},
"url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"ws": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
"integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==",
"requires": {
"async-limiter": "~1.0.0"
}
}
}
}

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

@ -0,0 +1,13 @@
{
"name": "",
"version": "",
"description": "",
"scripts": {
"test": "echo \"No tests yet...\""
},
"author": "",
"dependencies": {
"@microsoft/signalr": "^5.0.9",
"react-toastify": "^8.0.2"
}
}

Двоичные данные
Images/BrainstormAppM365.png Normal file

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

После

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

Двоичные данные
M365SubscriptionFlow/PresenceSubscriptionFlow.zip Normal file

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

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

@ -0,0 +1,60 @@
{
"swagger": "2.0",
"info": {
"title": "Subscription",
"description": "",
"version": "1.0"
},
"host": "graph.microsoft.com",
"basePath": "/",
"schemes": [
"https"
],
"consumes": [],
"produces": [],
"paths": {
"/v1.0/subscriptions": {
"post": {
"responses": {
"default": {
"description": "default",
"schema": {}
}
},
"summary": "Subscription",
"operationId": "Subscription",
"x-ms-visibility": "important",
"description": "Presence Subscription",
"parameters": [
{
"name": "body",
"in": "body",
"required": false,
"schema": {
"type": "object",
"properties": {}
}
}
]
}
}
},
"definitions": {},
"parameters": {},
"responses": {},
"securityDefinitions": {
"undefined": {
"type": "oauth2",
"flow": "accessCode",
"authorizationUrl": "https://login.windows.net/common/oauth2/authorize",
"tokenUrl": "https://login.windows.net/common/oauth2/authorize",
"scopes": {}
}
},
"security": [
{
"undefined": []
}
],
"tags": []
}

139
README.md Normal file
Просмотреть файл

@ -0,0 +1,139 @@
# Lets Brainstorm
Brainstorm is an example of using the Fluid Framework to build a collaborative line of business application. In this example each user can create their own sticky notes that is managed on a board. Ideas that have been "liked" appear
in a list and are sorted based upon the number likes.
Microsoft Graph functionality is also integrated in the `m365` branch to display user profiles and integrate user presence.
## Integrating real-time presence change notifications
The Brainstorm app receives the Microsoft Graph Change Notifications though Azure Event Hubs. To receive presence changes in real-time, Azure Functions and SignalR Service are communicating with Azure Event Hubs.
![Real-time notification architectural diagram](./Images/BrainstormAppM365.png)
### 1. Set up Azure Event Hubs and Azure KeyVault
To get [Microsoft Graph Presence](https://docs.microsoft.com/graph/api/presence-get?view=graph-rest-1.0&tabs=http&WT.mc_id=m365-37017-aycabas) change notifications delivered through Azure Event Hubs, setup Azure Event Hubs and Azure KeyVault by following the documentation: [Using Azure Event Hubs to receive change notifications](https://docs.microsoft.com/graph/change-notifications-delivery?WT.mc_id=m365-37017-aycabas#using-azure-event-hubs-to-receive-change-notifications)
**Notification Url** is created in this step as following and will be used in *Create a flow to execute subscription* step:
`EventHub:https://<azurekeyvaultname>.vault.azure.net/secrets/<secretname>?tenantId=<tenantId>`
### 2. Create subscription using Power Automate
A Power Automate flow and a custom connector is used to create subscription for Microsoft Graph Presence Change Notifications.
#### Register an app in Azure Active Directory
To register an app follow the steps below:
1. Sign in to the [Azure portal](https://portal.azure.com), select Azure Active Directory from the menu on the left.
1. Select **App registrations** tab, select **New registration** to register a new app and fill the details as following:
- Name: *Presence Subscription*
- Supported account types: *Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)*
- Redirect URI type: *Web* and URI: *https://global.consent.azure-apim.net/redirect*
- Select **Create**.
1. Select **Authentication** tab, enable `Access tokens` & `ID tokens` and **Save**.
1. Select **API permissions** tab, select **Add a permission**, choose *Microsoft Graph* and *Delegated Permissions*, add `Presence.Read`, `Presence.Read.All` in the app permissions.
1. Select **Certificates & Secrets** tab, select **New client secret** and copy the `Client Secret`.
1. Select **Overview** tab, copy `Application (client) ID`.
#### Create a custom connector for Microsoft Graph Subscription API
1. Visit [Power Automate Portal](https://flow.microsoft.com), select **Data** tab and **Custom connectors**.
1. Select **New custom connector** and **Import an OpenAPI file**, name your custom connector as `Subscription` and import `Subscription.swagger.json` file under **M365SubscriptionFlow** folder in Brainstorm app.
1. In the custom connector page, navigate to **2. Security** tab and paste your Azure Active Directory app `Application (client) ID` and `Client Secret`. Add *https://graph.microsoft.com* as **Resource URL**.
1. Select **Create connector**.
#### Create a flow to execute subscription
To create and run the subscription flow on Power Automate, follow the steps below:
1. In Power Automate Portal, select **Data** and **Connections**, select **Create a new connection**. Search for for your *Subscription* custom connector and select plus button to create connection. Repeat the same process to create new connections with *Microsoft Teams* and *Azure AD* connectors.
1. Select **My flows** and **Import**.
1. Select `PresenceSubscriptionFlow.zip` file under **M365SubscriptionFlow** folder in Brainstorm app.
1. In the import package page, configure import setup for resource types by following the steps below:
- *Flow*: change import setup from Update to `Create as new`.
- *Connector*: click on **Select during import**, choose **Subscription** custom connector listed and **Save**.
- *Subscription Connection*, *Microsoft Teams Connection* and *Azure AD Connection*: click on **Select during import**, select your connection from the list and **Save**.
1. Select **Import**.
1. Navigate to **My flows** tab in Power Automate Portal, select your *Presence Custom Connector Flow* and edit.
1. Select *Get a team* operation in the flow, remove the id in the field and select a preferred team from the list. Flow will create Presence Change Notification subscription for the selected team members.
1. Select *Subscription* operation in the flow, replace `notificationUrl` with your **Notification Url** created previously in *Set up Azure Event Hubs and Azure KeyVault* step.
1. **Save** and **Test** your flow. Presence changes for any member of the selected team can be monitored using Azure Event Hubs Dashboard. Alternatively, Visual Studio Code [Azure Event Hubs Explorer Extension](https://marketplace.visualstudio.com/items?itemName=Summer.azure-event-hub-explorer&WT.mc_id=m365-37017-aycabas) can be used for event monitoring.
### 3. Receive real-time change notifications in your app
Brainstorm app uses Azure SignalR Service and Azure Functions to receive Microsoft Graph Presence Change Notifications in real-time.
#### Configure Azure SignalR Service
Set up SignalR Service on Azure by following the documentation: [Create an Azure SignalR Service instance](https://docs.microsoft.com/azure/azure-signalr/signalr-quickstart-azure-functions-javascript?WT.mc_id=m365-37017-aycabas#create-an-azure-signalr-service-instance).
Once SignalR Service is deployed on Azure, navigate to your SignalR Service in the Azure Portal and select **Keys** tab. Copy connection string that will be used in the next step.
#### Setup the Functions locally
To setup and run the functions locally, follow the steps below:
1. Navigate to *SignalRConnection.tsx* file under the **src** folder of the project and replace `apiBaseUrl` with `http://localhost:7071/api`.
1. Create `local.settings.json` file under the *EventHubsFunctions* folder of the project and paste the following script:
```json
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"AzureSignalRConnectionString": "SignalR-Connection-String",
"AzureEventHubConnectionString": "EventHub-Connection-String"
},
"Host": {
"CORS": "http://localhost:3000",
"CORSCredentials": true
}
}
```
1. Replace `AzureSignalRConnectionString`, `AzureEventHubConnectionString` and `AzureWebJobsStorage` with your own connection strings.
1. Open a terminal window at the *EventHubsFunctions* folder of the project.
1. Run `npm install` and then `func start` from the *EventHubsFunctions* folder.
> **Note:** SignalR binding needs Azure Storage, but you can use local storage emulator when the Function is running locally. Please download and enable [Storage Emulator](https://docs.microsoft.com/azure/storage/common/storage-use-emulator?WT.mc_id=m365-37017-aycabas) to run the Functions locally.
## Running the App Locally
Follow the steps below to run this in local mode (Azure local service):
1. Run `npm install` from the brainstorm folder root
1. Run `npx @fluidframework/azure-local-service@latest` to start the Azure local service for testing and development
1. Run `npm run start` to start the client
1. Navigate to `http://localhost:3000` in a browser tab
📝 NOTE
Azure local service is a local, self-contained test service. Running `npx @fluidframework/azure-local-service@latest` from your terminal window will launch the Azure local server. The server will need to be started first in order to provide the ordering and storage requirement of the Fluid runtime.
## Running the App Locally with Azure Relay Service as the Fluid Service
To run this follow the steps below:
1. Go to the Azure portal and search for `Fluid Relay`.
1. Create a new Azure Fluid Relay resource and note the `Tenant Id`, `Primary key`, and `Orderer Endpoint` and `Storage Endpoint` values.
1. Rename the `.env-template` file in the root of the project to `.env`.
1. Replace the values in the `.env` file with the appropriate values from the Azure portal.
1. Open a terminal window at the root of the project.
1. Run `npm install` from the root
1. Run `export REACT_APP_FLUID_CLIENT=useAzure` in the terminal to create an environment variable (if using PowerShell run `$env:REACT_APP_FLUID_CLIENT='useAzure'`). This will cause the app to use Fluid Relay service instead of `azure-local-service` for the Fluid relay service.
)
1. Run `npm start` to start the client
1. Navigate to `http://localhost:3000` in a browser tab
## Using the Brainstorm App
1. Navigate to `http://localhost:3000`
You'll be taken to a url similar to 'http://localhost:3000/**#a9c16d13-43fa-413a-859c-514e5bcaba3c**' the path `#a9c16d13-43fa-413a-859c-514e5bcaba3c` specifies one brainstorm document.
2. Create another chrome tab with `http://localhost:3000/**#a9c16d13-43fa-413a-859c-514e5bcaba3c**`
Now you can create notes, write text, change colors and more!

43828
package-lock.json сгенерированный Normal file

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

70
package.json Normal file
Просмотреть файл

@ -0,0 +1,70 @@
{
"name": "learn-together-brainstorm",
"version": "0.49.2",
"description": "A simple brainstorming app built using Create React App plus a Fluid data model",
"homepage": "https://fluidframework.com",
"repository": "microsoft/FluidExamples",
"license": "MIT",
"author": "Microsoft and contributors",
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"start": "react-scripts start",
"start:server": "tinylicious",
"start:frs": "cross-env REACT_APP_FLUID_CLIENT='\"useAzure\"' npm run start",
"test": "react-scripts test",
"test:report": "echo No test for this example"
},
"eslintConfig": {
"extends": [
"react-app"
],
"rules": {
"no-restricted-globals": [
"error",
"event",
"fdescribe"
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@fluentui/react": "^8.29.2",
"@fluentui/react-icons-mdl2": "^1.2.1",
"fluid-framework": "^0.49.2",
"@fluidframework/azure-client": "^0.49.2",
"@fluidframework/test-runtime-utils": "^0.49.2",
"@microsoft/mgt-element": "^2.3.0",
"@microsoft/mgt-msal2-provider": "^2.3.0",
"@microsoft/mgt-react": "^2.3.0",
"@microsoft/signalr": "^5.0.11",
"cross-env": "^7.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-dnd": "^14.0.3",
"react-dnd-html5-backend": "^14.0.2",
"react-toastify": "^8.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/jest": "27.0.1",
"@types/node": "^12.19.0",
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"react-scripts": "4.0.3",
"typescript": "~4.4.2"
}
}

11
public/index.html Normal file
Просмотреть файл

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Lets Brainstorm</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="rootContent"></div>
</body>
</html>

25
public/manifest.json Normal file
Просмотреть файл

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

195
src/BrainstormModel.ts Normal file
Просмотреть файл

@ -0,0 +1,195 @@
import { IFluidContainer, ISharedMap, SharedMap } from "fluid-framework";
import { LikedNote, NoteData, Position, User } from "./Types";
const c_NoteIdPrefix = "noteId_";
const c_PositionPrefix = "position_";
const c_AuthorPrefix = "author_";
const c_votePrefix = "vote_";
const c_TextPrefix = "text_";
const c_ColorPrefix = "color_";
export type BrainstormModel = Readonly<{
CreateNote(noteId: string, myAuthor: User): NoteData;
MoveNote(noteId: string, newPos: Position): void;
SetNote(noteId: string, newCardData: NoteData): void;
SetNoteText(noteId: string, noteText: string): void;
SetNoteColor(noteId: string, noteColor: string): void;
LikeNote(noteId: string, author: User): void;
GetNoteLikedUsers(noteId: string): User[];
DeleteNote(noteId: string): void;
NoteIds: string[];
LikedNotes: LikedNote[];
setChangeListener(listener: (changed: any, local: any) => void): void;
removeChangeListener(listener: (changed: any, local: any) => void): void;
setSignedInUserId(userId: string): void;
deleteSignedOutUserId(userId: string): void;
SignedInUserIds: string[];
}>;
export function createBrainstormModel(fluid: IFluidContainer): BrainstormModel {
const sharedMap: ISharedMap = fluid.initialObjects.map as SharedMap;
const IsCompleteNote = (noteId: string) => {
if (
!sharedMap.get(c_PositionPrefix + noteId) ||
!sharedMap.get(c_AuthorPrefix + noteId)
) {
return false;
}
return true;
};
const IsDeletedNote = (noteId: string) => {
return sharedMap.get(c_NoteIdPrefix + noteId) === 0;
};
const SetNoteText = (noteId: string, noteText: string) => {
sharedMap.set(c_TextPrefix + noteId, noteText);
};
const SetNoteColor = (noteId: string, noteColor: string) => {
sharedMap.set(c_ColorPrefix + noteId, noteColor);
};
const numLikesCalculated = (noteId: string) => {
return Array.from(sharedMap
.keys())
.filter((key: string) => key.includes(c_votePrefix + noteId))
.filter((key: string) => sharedMap.get(key) !== undefined).length;
};
return {
CreateNote(noteId: string, myAuthor: User): NoteData {
const newNote: NoteData = {
id: noteId,
text: sharedMap.get(c_TextPrefix + noteId),
position: sharedMap.get(c_PositionPrefix + noteId)!,
author: sharedMap.get(c_AuthorPrefix + noteId)!,
numLikesCalculated: numLikesCalculated(noteId),
didILikeThisCalculated:
Array.from(sharedMap
.keys())
.filter((key: string) =>
key.includes(c_votePrefix + noteId + "_" + myAuthor.userId)
)
.filter((key: string) => sharedMap.get(key) !== undefined).length > 0,
color: sharedMap.get(c_ColorPrefix + noteId)!,
};
return newNote;
},
GetNoteLikedUsers(noteId: string): User[] {
return (
Array.from(sharedMap
.keys())
// Filter keys that represent if a note was liked
.filter((key: string) => key.startsWith(c_votePrefix + noteId))
.filter((key: string) => sharedMap.get(key) !== undefined)
// Return the user associated with the like
.map((value: string) => sharedMap.get(value)!)
);
},
MoveNote(noteId: string, newPos: Position) {
sharedMap.set(c_PositionPrefix + noteId, newPos);
},
SetNote(noteId: string, newCardData: NoteData) {
sharedMap.set(c_PositionPrefix + noteId, newCardData.position);
sharedMap.set(c_AuthorPrefix + noteId, newCardData.author);
SetNoteText(newCardData.id, newCardData.text!);
sharedMap.set(c_NoteIdPrefix + noteId, 1);
sharedMap.set(c_ColorPrefix + noteId, newCardData.color);
},
SetNoteText,
SetNoteColor,
LikeNote(noteId: string, author: User) {
const voteString = c_votePrefix + noteId + "_" + author.userId;
sharedMap.get(voteString) === author
? sharedMap.set(voteString, undefined)
: sharedMap.set(voteString, author);
},
DeleteNote(noteId: string) {
sharedMap.set(c_NoteIdPrefix + noteId, 0);
},
get NoteIds(): string[] {
return (
Array.from(sharedMap
.keys())
// Only look at keys which represent if a note exists or not
.filter((key: String) => key.includes(c_NoteIdPrefix))
// Modify the note ids to not expose the prefix
.map((noteIdWithPrefix) =>
noteIdWithPrefix.substring(c_NoteIdPrefix.length)
)
// Remove notes which are incomplete or deleted
.filter((noteId) => IsCompleteNote(noteId) && !IsDeletedNote(noteId))
);
},
get LikedNotes(): LikedNote[] {
return (
Array.from(sharedMap
.keys())
// Only look at keys which represent if a note exists or not
.filter((key: String) => key.includes(c_NoteIdPrefix))
// Modify the note ids to not expose the prefix
.map((noteIdWithPrefix) =>
noteIdWithPrefix.substring(c_NoteIdPrefix.length)
)
// Remove notes which are incomplete or deleted
.filter((noteId) =>
!IsDeletedNote(noteId) && numLikesCalculated(noteId) > 0 &&
sharedMap.get(c_TextPrefix + noteId)
)
.map((noteId) => {
const text = sharedMap.get(c_TextPrefix + noteId);
const color = sharedMap.get(c_ColorPrefix + noteId);
const author = sharedMap.get(c_AuthorPrefix + noteId);
return {
text,
color,
author,
numLikesCalculated: numLikesCalculated(noteId)
};
})
.sort((a: LikedNote, b: LikedNote) => {
return b.numLikesCalculated - a.numLikesCalculated;
})
);
},
setChangeListener(listener: (changed: any, local: any) => void): void {
sharedMap.on("valueChanged", listener);
},
removeChangeListener(listener: (changed: any, local: any) => void): void {
sharedMap.off("valueChanged", listener);
},
setSignedInUserId(userId: string) {
let userIds = this.SignedInUserIds ?? [];
if (!userIds.includes(userId)) {
userIds.push(userId);
}
sharedMap.set("userIds", userIds);
},
deleteSignedOutUserId(userId: string) {
let userIds = this.SignedInUserIds ?? [];
userIds = userIds.filter((uid: string) => uid !== userId);
sharedMap.set("userIds", userIds);
},
get SignedInUserIds(): string[] {
return sharedMap.get("userIds") as string[];
},
};
}

29
src/Config.ts Normal file
Просмотреть файл

@ -0,0 +1,29 @@
import { AzureClientProps, LOCAL_MODE_TENANT_ID } from "@fluidframework/azure-client";
import { InsecureTokenProvider } from "@fluidframework/test-runtime-utils";
import { SharedMap } from "fluid-framework";
import { generateUser } from './Utils';
export const useAzure = process.env.REACT_APP_FLUID_CLIENT === "useAzure";
export const user = generateUser();
export const containerSchema = {
name: "brainstorm",
initialObjects: {
map: SharedMap,
},
}
export const connectionConfig: AzureClientProps = useAzure ? {
connection: {
tenantId: process.env.REACT_APP_TENANT_ID as string,
tokenProvider: new InsecureTokenProvider(process.env.REACT_APP_PRIMARY_KEY as string, user),
orderer: process.env.REACT_APP_ORDERER_ENDPOINT as string,
storage: process.env.REACT_APP_STORAGE_ENDPOINT as string
}
} : {
connection: {
tenantId: LOCAL_MODE_TENANT_ID,
tokenProvider: new InsecureTokenProvider("fooBar", user),
orderer: "http://localhost:7070",
storage: "http://localhost:7070",
}
};

141
src/GraphNotifications.tsx Normal file
Просмотреть файл

@ -0,0 +1,141 @@
import { MouseEvent } from 'react';
import { Providers, ProviderState } from '@microsoft/mgt-element';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { Person, ViewType } from '@microsoft/mgt-react';
import { SendIcon } from '@fluentui/react-icons-mdl2';
import { getAppSignedInUserIds } from './view/Header';
import { dispatch } from './Utils';
export async function GraphChat(userId: undefined) {
const provider = Providers.globalProvider;
if (provider.state === ProviderState.SignedIn) {
let graphClient = provider.graph.client;
let chatDetails = await graphClient.api('/me/chats').get();
const strChatDetails = JSON.stringify(chatDetails);
const parseChat = JSON.parse(strChatDetails);
console.log(`chat details: + ${strChatDetails}`);
if (parseChat.value != null) {
console.log(`value: ${parseChat.value}`);
//for(let chat of parseChat.value)
for (let chat = 0; chat < parseChat.value.length; chat++) {
if (parseChat.value[chat].chatType === "oneOnOne") {
let getChatMembers = await graphClient.api('/chats/' + parseChat.value[chat].id)
.expand('members')
.get();
const strMembers = JSON.stringify(getChatMembers)
const parseMembers = JSON.parse(strMembers);
console.log(`members details: ${strMembers}`);
for (let member of parseMembers.members) {
if (member.userId === userId) {
const chatMessage = {
body: {
content: `Come collaborate with us! ${window.location}`
}
};
await graphClient.api('/chats/' + parseChat.value[chat].id + '/messages')
.post(chatMessage);
}
}
}
else {
console.log(`there is no oneOnOne chat found`);
}
}
}
else {
const chat = {
chatType: 'oneOnOne',
members: [
{
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: ['owner'],
'user@odata.bind': 'https://graph.microsoft.com/v1.0/users(\'' + userId + '\')'
},
{
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: ['owner'],
'user@odata.bind': 'https://graph.microsoft.com/v1.0/users(\'' + userId + '\')'
}
]
};
await graphClient.api('/chats')
.post(chat);
}
}
}
export async function Notification(message: string) {
const provider = Providers.globalProvider;
//SignalR Message - Presence Change Notifications
// console.log(message);
const parsedMessage = JSON.parse(message);
// console.log(parsedMessage);
const userId = parsedMessage.id;
const userAvailability = parsedMessage.availability;
// Dispatch user availability information to listeners
dispatch({ type: 'userAvailabilityChanged', payload: { userId, availability: userAvailability }});
console.log("userId:\n" + userId);
console.log(`availability: ${userAvailability}`);
//get logged in user's id
let graphClient = provider.graph.client;
let loggedInUser = await graphClient.api('/me').get();
const strLoggedInUser = JSON.stringify(loggedInUser);
const parseLoggedInUser = JSON.parse(strLoggedInUser);
//notification id to prevent duplicate toast notifications
const customId = userId;
const appSignedInUserIds = getAppSignedInUserIds();
console.log('Signed in userId\n', parseLoggedInUser.id)
console.log('Signed in userIds: ', appSignedInUserIds);
// Check if user should be invited to collaboration session
if (userAvailability === "Available" && userId !== parseLoggedInUser.id && !appSignedInUserIds.includes(userId)) {
const Msg = () => (
<div className="ToastDiv">
<Person
userId={userId}
view={ViewType.twolines}
showPresence />
<button
className="ToastButton"
id="ToastButton"
onClick={handleMouseEvent}>Invite <SendIcon></SendIcon>
</button>
</div>
)
const handleMouseEvent = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
GraphChat(userId);
};
toast(Msg, { toastId: customId, autoClose: false, closeOnClick: true, });
}
// Only show toast if user is already signed into the app and collaborating
else if (userId !== parseLoggedInUser.id && appSignedInUserIds.includes(userId)) {
const Msg = () => (
<div className="ToastDiv">
<Person
userId={userId}
view={ViewType.threelines}
showPresence />
<br /><br />
User status changed to {userAvailability}
</div>
)
toast(Msg, { toastId: customId, autoClose: 5000 });
}
}

56
src/Navbar.tsx Normal file
Просмотреть файл

@ -0,0 +1,56 @@
import React, { useRef, useState, useEffect } from 'react';
import { IFluidContainer } from '@fluidframework/fluid-static';
import { Providers } from '@microsoft/mgt-element';
import { Login } from '@microsoft/mgt-react';
import { BrainstormModel, createBrainstormModel } from "./BrainstormModel";
import { User } from './Types';
export function Navbar(props: { container: IFluidContainer, setSignedInUser: (user: User) => void}) {
const [model] = useState<BrainstormModel>(createBrainstormModel(props.container));
const userId = useRef<string>("");
useEffect(() => {
const login = document.querySelector("mgt-login");
function userSignIn(e: Event) {
console.log("User signed in");
Providers.globalProvider.graph.client
.api('me')
.get()
.then((me: any) => {
if (me && me.id) {
userId.current = me.id;
props.setSignedInUser({userName: '', userId: me.id});
model.setSignedInUserId(me.id);
}
});
};
function userSignOut(e: Event) {
console.log("User logged out");
if (userId.current) {
model.deleteSignedOutUserId(userId.current);
}
}
login?.addEventListener("loginCompleted", userSignIn);
login?.addEventListener("logoutInitiated", userSignOut);
return () => {
login?.removeEventListener("loginCompleted", userSignIn);
login?.removeEventListener("logoutCompleted", userSignOut);
};
}, [model, props]);
return (
<header>
<div className="grid-container">
<div className="left title">Let's Brainstorm</div>
<div className="right login end">
<Login></Login>
</div>
</div>
</header>
);
}

49
src/SignalRConnection.tsx Normal file
Просмотреть файл

@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { ToastContainer, Slide } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import * as signalR from '@microsoft/signalr';
import { Notification } from './GraphNotifications';
function SignalRConnection() {
useEffect(() => {
//SignalR Connection
const apiBaseUrl = "https://notifications-function.azurewebsites.net/api";
const connection = new signalR.HubConnectionBuilder()
.withUrl(apiBaseUrl)
.configureLogging(signalR.LogLevel.Information)
.build();
connection.start().then(function () {
console.log("connected");
}).catch(function (err) {
return console.error(err.toString());
});
connection.on('newMessage', (message) => {
Notification(message);
});
}, []);
return (
<div className="App">
<ToastContainer position="bottom-right"
hideProgressBar={true}
newestOnTop={true}
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
transition={Slide}
icon={false}
bodyClassName={() => "text-sm font-white font-med block p-3"}
style={{border:"#29B702", padding:"1px" , margin:"5px"}}
>
</ToastContainer>
</div>
);
}
export default SignalRConnection;

32
src/Types.ts Normal file
Просмотреть файл

@ -0,0 +1,32 @@
import { AzureMember } from "@fluidframework/azure-client";
export type Position = Readonly<{ x: number; y: number }>;
export type User = { userName: string, userId: string };
export type UserAvailability = {userId: string, availability: string};
export type NoteData = Readonly<{
id: any;
text?: string;
author: User;
position: Position;
numLikesCalculated: number;
didILikeThisCalculated: boolean;
color: ColorId;
}>;
export type ColorId =
| "Blue"
| "Green"
| "Yellow"
| "Pink"
| "Purple"
| "Orange";
export type LikedNote = {
text: string,
color: string,
author: AzureMember,
numLikesCalculated: number
};

137
src/Utils.ts Normal file

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

78
src/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,78 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/
import { initializeIcons, ThemeProvider } from "@fluentui/react";
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { BrainstormView } from './view/BrainstormView';
import "./view/index.css";
import "./view/App.css";
import { themeNameToTheme } from './view/Themes';
import { Navbar } from './Navbar';
import { Providers } from '@microsoft/mgt-element';
import { Msal2Provider } from '@microsoft/mgt-msal2-provider';
import useIsSignedIn from './useIsSignedIn';
import UserContext from "./userContext";
import { User } from "./Types";
import { getFluidContainer } from "./Utils";
import SignalRConnection from "./SignalRConnection";
import { CacheService } from '@microsoft/mgt-react';
Providers.globalProvider = new Msal2Provider({
clientId: '259fb0fd-a369-4003-a93c-66c8405567f3'
});
CacheService.config.presence.invalidationPeriod = 5000; // 10 seconds
export async function start() {
initializeIcons();
let { container } = await getFluidContainer();
if (!container.connected) {
await new Promise<void>((resolve) => {
container.once("connected", () => {
resolve();
});
});
}
function Main(props: any) {
const [isSignedIn] = useIsSignedIn();
const [user, setUser] = useState<User>({ userName: '', userId: '' });
function setSignedInUser(user: User) {
setUser(user);
}
return (
<React.StrictMode>
<ThemeProvider theme={themeNameToTheme("default")}>
<UserContext.Provider value={user}>
<Navbar container={container} setSignedInUser={setSignedInUser} />
<main>
{isSignedIn &&
<div>
<SignalRConnection />
<BrainstormView container={container} />
</div>
}
{!isSignedIn &&
<h2>Welcome to Brainstorm! Please sign in to get started.</h2>
}
</main>
</UserContext.Provider>
</ThemeProvider>
</React.StrictMode>
)
}
ReactDOM.render(
<Main />,
document.getElementById('root')
);
}
start().catch((error) => console.error(error));

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

@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { UserAvailability } from './Types';
export const eventBus = {
on(event: string, callback: any) {
document.addEventListener(event, (e: Event) => callback((e as CustomEvent).detail));
},
dispatch(eventName: string, data: any) {
document.dispatchEvent(new CustomEvent(eventName, { detail: data }));
},
remove(event: string, callback: any) {
document.removeEventListener(event, callback);
},
};
export default function useGetUserAvailability(): [UserAvailability] {
const [userAvailability, setUserAvailability] = useState<UserAvailability>({ userId: '', availability: '' });
useEffect(() => {
const updateUserAvailability = (data: UserAvailability) => {
setUserAvailability(data);
};
eventBus.on('userAvailabilityChanged', updateUserAvailability);
return () => {
eventBus.remove('userAvailabilityChanged', updateUserAvailability);
}
}, []);
return [userAvailability];
}
export const getUserAvailabilityValue = (userId: string, userAvailability: UserAvailability) => {
if (userId === userAvailability?.userId) {
return userAvailability.availability;
}
return null;
}

22
src/useIsSignedIn.ts Normal file
Просмотреть файл

@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { Providers, ProviderState } from '@microsoft/mgt-element';
export default function useIsSignedIn(): [boolean] {
const [isSignedIn, setIsSignedIn] = useState(false);
useEffect(() => {
const updateState = () => {
const provider = Providers.globalProvider;
setIsSignedIn(provider && provider.state === ProviderState.SignedIn);
};
Providers.onProviderUpdated(updateState);
updateState();
return () => {
Providers.removeProviderUpdatedListener(updateState);
}
}, []);
return [isSignedIn];
}

9
src/userContext.ts Normal file
Просмотреть файл

@ -0,0 +1,9 @@
import React from 'react';
import { User } from './Types';
const UserContext = React.createContext({
userName: '',
userId: ''
} as User);
export default UserContext;

184
src/view/App.css Normal file
Просмотреть файл

@ -0,0 +1,184 @@
.App {
text-align: center;
}
main {
margin: 10px 30px 0px 20px;
}
h1 {
color: gray;
}
.PropName {
font-weight: bold;
color: #6264a7;
}
.Logo {
font-size: 45pt;
color: #6264a7;
}
.Error {
color: red;
}
#NoteSpace {
height: 100vh
}
header {
background-color: #0266B7;
height: 50px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-areas: "left right";
align-items: center;
height: 100%;
}
.left {
grid-area: left;
}
.right {
grid-area: right;
}
.end {
justify-self: end;
}
.start {
justify-self: start;
}
mgt-login {
--button-color: white;
}
.title {
margin-left: 25px;
font-size: 20px;
color: white;
}
.login {
color: white;
margin-right: 25px;
}
.white {
color: white;
}
.mr-15 {
margin-right: 15px;
}
.ml-10 {
margin-left: 10px;
}
.pb-5 {
padding-bottom: 5px;
}
.selected-items {
margin: 0 auto;
max-width: 960px;
background-color: #efefef;
}
.selected-items h2 {
height: 35px;
background-color: #6e6e6e;
}
.selected-items h2 div {
margin-left: 10px;
color: white;
}
.heading {
font-size: 20px;
margin-bottom: 2px;
}
.items-list ul {
list-style-type: none;
padding: 0;
margin: 5px 0px 0px 10px;
padding-bottom: 2px;
}
.items-list li {
margin-bottom: 5px;
}
.selecteditem {
font-size: 18px;
}
.icon-wrapper {
position: relative;
text-align: center;
height: 30px;
margin-right: 8px;
}
.circle-icon {
font-size: 30px;
}
.circle-icon-overlay {
font-size: 18px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
padding-bottom: 2px;
}
.ToastButton {
margin-top: 15px;
padding: 8px;
background-color: #29B702;
border-radius: 3px;
color: white;
font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 14px;
border: white;
display: inline-block;
width: 35%;
text-align: center;
align-content: flex-end;
cursor: pointer;
}
.note-person mgt-person {
text-align: start;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--avatar-size: 28px;
}
.ToastButton:hover {
background-color: rgb(41, 183, 2, .5);
}
.Toastify mgt-person {
text-align: start;
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--avatar-size: 50px;
--font-size: 16px;
--line2-color: gray;
}
.ToastDiv {
border-radius: 5px;
padding: 4px 4px 9px 0px;
margin: 0%;
}

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

@ -0,0 +1,44 @@
import { mergeStyles, Spinner } from "@fluentui/react";
import { IFluidContainer } from "fluid-framework";
import { useState, useContext } from "react";
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { BrainstormModel, createBrainstormModel } from "../BrainstormModel";
import UserContext from "../userContext";
import { Header } from "./Header";
import { ItemsList } from "./ItemsList";
import { NoteSpace } from "./NoteSpace";
export const BrainstormView = (props: { container: IFluidContainer }) => {
const [model] = useState<BrainstormModel>(createBrainstormModel(props.container));
const user = useContext(UserContext);
const wrapperClass = mergeStyles({
height: "100%",
display: "flex",
flexDirection: "column"
});
if (user === undefined) {
return <Spinner />;
}
return (
<div className={wrapperClass}>
<Header
model={model}
author={user}
/>
<div className="items-list">
<ItemsList model={model} />
</div>
<div>
<DndProvider backend={HTML5Backend}>
<NoteSpace
model={model}
author={user}
/>
</DndProvider>
</div>
</div>
);
};

23
src/view/Color.ts Normal file
Просмотреть файл

@ -0,0 +1,23 @@
import { ColorId } from "../Types";
export type ColorValues = { base: string; dark: string; light: string };
export const ColorOrder: ColorId[] = [
"Blue",
"Green",
"Yellow",
"Pink",
"Purple",
"Orange",
];
export const DefaultColor = ColorOrder[0];
export const ColorOptions: { [key in ColorId]: ColorValues } = {
Blue: { base: "#0078D4", dark: "#99C9EE", light: "#CCE4F6" },
Green: { base: "#005E50", dark: "#99BFB9", light: "#CCDFDC" },
Yellow: { base: "#F2C811", dark: "#FAE9A0", light: "#FCF4CF" },
Pink: { base: "#E3008C", dark: "#F499D1", light: "#F9CCE8" },
Purple: { base: "#8764B8", dark: "#CFC1E3", light: "#E7E0F1" },
Orange: { base: "#CA5010", dark: "#EAB99F", light: "#F4DCCF" },
};

35
src/view/ColorPicker.tsx Normal file
Просмотреть файл

@ -0,0 +1,35 @@
import React from "react";
import { SwatchColorPicker, IColorCellProps } from "@fluentui/react";
import { ColorOptions, ColorOrder } from "./Color";
import { ColorId } from "../Types";
export type ColorButtonProps = {
parent?: any,
selectedColor: ColorId;
setColor: (color: ColorId) => void;
};
export function ColorPicker(props: ColorButtonProps) {
const { selectedColor, setColor } = props;
const colorCells = ColorOrder.map((id) => colorOptionToCell(id));
const onChange = (_event: React.FormEvent<HTMLElement>, colorId: string | undefined) => {
props.parent.current.dismissMenu();
setColor(colorId as ColorId);
};
return (
<SwatchColorPicker
columnCount={6}
colorCells={colorCells}
defaultSelectedId={selectedColor}
onChange={onChange}
/>
);
}
function colorOptionToCell(id: ColorId): IColorCellProps {
return {
id: id,
color: ColorOptions[id].dark,
};
}

136
src/view/Header.tsx Normal file
Просмотреть файл

@ -0,0 +1,136 @@
import React, { useEffect, useState, useRef } from "react";
import {
CommandBar,
ICommandBarItemProps,
} from "@fluentui/react";
import { MgtTemplateProps, People, Person } from '@microsoft/mgt-react';
import { BrainstormModel } from "../BrainstormModel";
import { DefaultColor } from "./Color";
import { ColorPicker } from "./ColorPicker";
import { NoteData, User, UserAvailability } from "../Types";
import { NOTE_SIZE } from "./Note.style";
import { getUserAvailabilityValue, useEventBus, uuidv4 } from "../Utils";
export interface HeaderProps {
model: BrainstormModel;
author: User;
}
// Expose signed in userIds to other parts of app (quick and easy way)
let appSignedInUserIds: string[] = [];
export function getAppSignedInUserIds() {
return appSignedInUserIds;
}
export function Header(props: HeaderProps) {
const colorButtonRef = useRef<any>();
const [color, setColor] = useState(DefaultColor);
const { model } = props;
const [signedInUserIds, setSignedInUserIds ] = useState<string[]>([]);
const [userAvailability, setUserAvailability] = useState<UserAvailability>({ userId: '', availability: '' });
useEventBus(
'userAvailabilityChanged',
(data: any) => setUserAvailability(data.payload)
);
// This runs when via model changes whether initiated by user or from external
useEffect(() => {
function signedInUserIdsChanged(changed: any, local: any) {
if (changed.key === "userIds") {
setSignedInUserIds(model.SignedInUserIds);
// Update array that is used outside of component
appSignedInUserIds = model.SignedInUserIds;
}
}
model.setChangeListener(signedInUserIdsChanged);
return () => model.removeChangeListener(signedInUserIdsChanged);
}, [model]);
const onAddNote = () => {
const { scrollHeight, scrollWidth } = document.getElementById("NoteSpace")!;
const id = uuidv4();
const newCardData: NoteData = {
id,
position: {
x: Math.floor(Math.random() * (scrollWidth - NOTE_SIZE.width)),
y: Math.floor(Math.random() * (scrollHeight - NOTE_SIZE.height)),
},
author: props.author,
numLikesCalculated: 0,
didILikeThisCalculated: false,
color
};
props.model.SetNote(id, newCardData);
};
const items: ICommandBarItemProps[] = [
{
key: "add",
text: "Add note",
onClick: onAddNote,
iconProps: {
iconName: "QuickNote",
},
},
{
componentRef: colorButtonRef,
key: "color",
text: "Default Color",
iconProps: {
iconName: "Color",
},
subMenuProps: {
key: "color-picker",
items: [{ key: "foo" }],
onRenderMenuList: () => (
<ColorPicker
parent={colorButtonRef}
selectedColor={color}
setColor={setColor}
/>
),
},
},
];
const farItems: ICommandBarItemProps[] = [
{
key: "presence",
onRender: () => {
const PersonTemplate = ({ dataContext }: MgtTemplateProps) => {
const person = dataContext.person;
const availability = getUserAvailabilityValue(person.id, userAvailability);
const baseProps = { userId: person.id, showPresence: true, userAvailability: dataContext.userAvailability };
const personProps = (availability)
? {...baseProps, personPresence:{ activity: availability, availability: availability } }
: baseProps;
return (
<Person {...personProps} />
);
};
return (
<div>
<People userIds={signedInUserIds} showPresence templateContext={{userAvailability}}>
<PersonTemplate template="person" />
</People>
</div>
);
},
},
];
return (
<CommandBar
styles={{ root: { paddingLeft: 0 } }}
items={items}
farItems={farItems}
/>
);
}

62
src/view/ItemsList.tsx Normal file
Просмотреть файл

@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { BrainstormModel } from "../BrainstormModel";
import { ColorId, LikedNote } from "../Types";
import { CircleFillIcon } from '@fluentui/react-icons-mdl2';
import { ColorOptions } from "./Color";
export type ItemsListProps = Readonly<{
model: BrainstormModel;
}>;
export function ItemsList(props: ItemsListProps) {
const { model } = props;
const [notes, setNotes] = useState<readonly LikedNote[]>([]);
// This runs when via model changes whether initiated by user or from external
useEffect(() => {
const syncLocalAndFluidState = () => {
const likedNotes: LikedNote[] = model.LikedNotes;
setNotes(likedNotes);
};
syncLocalAndFluidState();
model.setChangeListener(syncLocalAndFluidState);
return () => model.removeChangeListener(syncLocalAndFluidState);
}, [model]);
return (
<div className="items-list">
<div className="selected-items">
<h2 className="grid-container">
<div className="left heading">Selected Items</div>
<div className="right end mr-15 heading">Votes</div>
</h2>
{!!notes.length &&
<ul>
{notes.map((note, i) => {
const iconColor = ColorOptions[note.color as ColorId].base;
return (
<li key={i}>
<div className="grid-container">
<div className="left selecteditem">{note.text}</div>
<div className="right end mr-15">
<div className="icon-wrapper">
<CircleFillIcon className="circle-icon"
style={{color: iconColor}} />
<span className="circle-icon-overlay">{note.numLikesCalculated}</span>
</div>
</div>
</div>
</li>
)
})}
</ul>
}
{notes.length === 0 &&
<div className="selecteditem ml-10 pb-5">No notes selected</div>
}
</div>
</div>
);
}

67
src/view/Note.style.ts Normal file
Просмотреть файл

@ -0,0 +1,67 @@
import { IRawStyle, IStyle, ITooltipHostStyles, IButtonStyles } from '@fluentui/react';
import { ColorOptions } from "./Color";
import { ColorId } from "../Types";
export const NOTE_SIZE = {
width: 250,
height: 75
}
export const tooltipHostStyle: Partial<ITooltipHostStyles> = {
root: { display: "inline-block" },
};
export const iconStyle: React.CSSProperties = {
color: "black",
fontSize: "10px",
};
export const deleteButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent" },
rootHovered: { backgroundColor: "transparent" },
rootPressed: { backgroundColor: "transparent" },
icon: { fontSize: "13px" },
iconHovered: { fontSize: "15px" }
};
export const colorButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent " },
rootHovered: { backgroundColor: "transparent" },
rootPressed: { backgroundColor: "transparent" },
rootExpanded: { backgroundColor: "transparent" },
rootExpandedHovered: { backgroundColor: "transparent" },
iconHovered: { fontSize: "18px" },
iconExpanded: { fontSize: "18px" }
};
export const likesButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent" },
rootHovered: { backgroundColor: "transparent", fontSize: "18px" },
rootPressed: { backgroundColor: "transparent" },
iconHovered: { fontSize: "18px" }
};
export const likesButtonAuthorStyle: IButtonStyles = {
root: { backgroundColor: "transparent" },
rootHovered: { backgroundColor: "transparent", fontSize: "14px" },
rootPressed: { backgroundColor: "transparent" }
};
export function getRootStyleForColor(color: ColorId): IStyle {
return {
background: ColorOptions[color].light,
position: "absolute",
borderRadius: "2px",
boxShadow:
"rgb(0 0 0 / 13%) 0px 1.6px 3.6px 0px, rgb(0 0 0 / 11%) 0px 0.3px 0.9px 0px",
width: NOTE_SIZE.width,
minHeight: NOTE_SIZE.height
};
}
export function getHeaderStyleForColor(color: ColorId): IRawStyle {
if (color === undefined) {
return { backgroundColor: ColorOptions["Blue"].dark };
}
return { backgroundColor: ColorOptions[color].dark };
}

63
src/view/Note.tsx Normal file
Просмотреть файл

@ -0,0 +1,63 @@
import {
mergeStyles,
} from "@fluentui/react";
import React from "react";
import { useDrag } from "react-dnd";
import { DefaultColor } from "./Color";
import {
getRootStyleForColor
} from "./Note.style";
import { NoteData, Position, User } from "../Types";
import { NoteHeader } from "./NoteHeader";
import { NoteBody } from "./NoteBody";
export type NoteProps = Readonly<{
id: string;
user: User;
setPosition: (position: Position) => void;
onLike: () => void;
getLikedUsers: () => User[];
onDelete: () => void;
onColorChange: (color: string) => void;
setText: (text: string) => void;
}> &
Pick<
NoteData,
| "author"
| "position"
| "color"
| "didILikeThisCalculated"
| "numLikesCalculated"
| "text"
>;
export function Note(props: NoteProps) {
const {
id,
position: { x: left, y: top },
color = DefaultColor,
setText,
text
} = props;
const [, drag] = useDrag(
() => ({
type: "note",
item: { id, left, top },
}),
[id, left, top]
);
const rootClass = mergeStyles(getRootStyleForColor(color));
return (
<div className={rootClass} ref={drag} style={{ left, top }}>
<NoteHeader {...props} />
<NoteBody setText={setText} text={text} color={color} />
</div>
);
}

28
src/view/NoteBody.tsx Normal file
Просмотреть файл

@ -0,0 +1,28 @@
import React from "react";
import { TextField } from "@fluentui/react";
import { NoteData } from "../Types";
import { ColorOptions, DefaultColor } from "./Color";
export type NoteBodyProps = Readonly<{
setText(text: string): void;
}> &
Pick<NoteData, "text" | "color">;
export function NoteBody(props: NoteBodyProps) {
const { setText, text, color = DefaultColor } = props;
return (
<div style={{ flex: 1 }}>
<TextField
styles={{ fieldGroup: { background: ColorOptions[color].light } }}
borderless
multiline
resizable={false}
autoAdjustHeight
onChange={(event) => setText(event.currentTarget.value)}
value={text}
placeholder={"Enter Text Here"}
/>
</div>
);
}

170
src/view/NoteHeader.tsx Normal file
Просмотреть файл

@ -0,0 +1,170 @@
import {
CommandBar,
CommandBarButton,
DirectionalHint,
ICommandBarItemProps,
IResizeGroupProps,
ITooltipProps,
mergeStyles,
TooltipHost,
} from "@fluentui/react";
import { useRef, memo, useState } from "react";
import { Person } from "@microsoft/mgt-react";
import { ColorPicker } from "./ColorPicker";
import {
getHeaderStyleForColor,
deleteButtonStyle,
colorButtonStyle,
likesButtonStyle,
tooltipHostStyle,
likesButtonAuthorStyle,
} from "./Note.style";
import { ReactionListCallout } from "./ReactionListCallout";
import { NoteProps } from "./Note"
import { UserAvailability } from "../Types";
import { getUserAvailabilityValue, useEventBus } from "../Utils";
const HeaderComponent = (props: NoteProps) => {
const colorButtonRef = useRef();
const { user } = props;
const [userAvailability, setUserAvailability] = useState<UserAvailability>({ userId: '', availability: '' });
useEventBus(
'userAvailabilityChanged',
(data: any) => setUserAvailability(data.payload)
);
const headerProps = {
className: mergeStyles(getHeaderStyleForColor(props.color))
};
const likeBtnTooltipProps: ITooltipProps = {
onRenderContent: () => {
const likedUserList = props.getLikedUsers();
if (likedUserList.length === 0) {
// Don't render a tooltip if no users reacted.
return null;
}
return (
<ReactionListCallout
label={"Like Reactions"}
reactionIconName={"Like"}
usersToDisplay={likedUserList}
/>
);
},
calloutProps: {
beakWidth: 10,
},
};
const items: ICommandBarItemProps[] = [
{
key: "persona",
onRender: () => {
const authorId = props.author.userId;
const availability = getUserAvailabilityValue(authorId, userAvailability);
const baseProps = { userId: authorId, showPresence: true };
const personProps = (availability)
? {...baseProps, personPresence:{activity: availability, availability: availability} }
: baseProps;
return (
<TooltipHost
styles={{ root: { alignSelf: "center", display: "block", marginLeft: "5px" } }}
content={props.author.userName}
>
<div className="note-person">
<Person {...personProps } />
</div>
</TooltipHost>
);
},
},
];
const farItems: ICommandBarItemProps[] = [
{
key: "likes",
onClick: props.onLike,
text: props.numLikesCalculated.toString(),
iconProps: {
iconName: props.didILikeThisCalculated ? "LikeSolid" : "Like",
},
buttonStyles: isAuthorNote() ? likesButtonAuthorStyle : likesButtonStyle,
commandBarButtonAs: (props) => {
return (
<TooltipHost
tooltipProps={likeBtnTooltipProps}
directionalHint={DirectionalHint.topAutoEdge}
styles={tooltipHostStyle}
>
<CommandBarButton {...(props as any)} />
</TooltipHost>
);
},
},
{
// @ts-ignore
componentRef: colorButtonRef,
key: "color",
iconProps: {
iconName: "Color",
},
subMenuProps: {
key: "color-picker",
items: [{ key: "foo" }],
onRenderMenuList: () => (
<ColorPicker
parent={colorButtonRef}
selectedColor={props.color!}
setColor={(color) => props.onColorChange(color)}
/>
),
},
buttonStyles: colorButtonStyle,
},
{
key: "delete",
iconProps: { iconName: "Clear" },
title: "Delete Note",
onClick: props.onDelete,
buttonStyles: deleteButtonStyle,
},
];
// Don't add links button for author of note
function isAuthorNote() {
return user.userId && props.author.userId === user.userId;
}
const nonResizingGroup = (props: IResizeGroupProps) => (
<div>
<div style={{ position: "relative" }}>
{props.onRenderData(props.data)}
</div>
</div>
);
return (
<div {...headerProps}>
<CommandBar
resizeGroupAs={nonResizingGroup}
styles={{
root: { padding: 0, height: 36, backgroundColor: "transparent" },
}}
items={items}
farItems={farItems}
/>
</div>
)
}
export const NoteHeader = memo(HeaderComponent, (prevProps, nextProps) => {
return prevProps.color === nextProps.color
&& prevProps.numLikesCalculated === nextProps.numLikesCalculated
&& prevProps.didILikeThisCalculated === nextProps.didILikeThisCalculated
})

107
src/view/NoteSpace.tsx Normal file
Просмотреть файл

@ -0,0 +1,107 @@
import { IStyle, mergeStyles, ThemeProvider } from "@fluentui/react";
import React from "react";
import { useDrop } from 'react-dnd';
import { NoteData, Position, User } from "../Types";
import { Note } from "./Note";
import { BrainstormModel } from "../BrainstormModel";
import { lightTheme } from "./Themes";
export type NoteSpaceProps = Readonly<{
model: BrainstormModel;
author: User;
}>;
export function NoteSpace(props: NoteSpaceProps) {
const { model } = props;
const [notes, setNotes] = React.useState<readonly NoteData[]>([]);
// This runs when via model changes whether initiated by user or from external
React.useEffect(() => {
const syncLocalAndFluidState = () => {
const noteDataArr = [];
const ids: string[] = model.NoteIds;
// Recreate the list of cards to re-render them via setNotes
for (let noteId of ids) {
const newCardData: NoteData = model.CreateNote(noteId, props.author);
noteDataArr.push(newCardData);
}
setNotes(noteDataArr);
};
syncLocalAndFluidState();
model.setChangeListener(syncLocalAndFluidState);
return () => model.removeChangeListener(syncLocalAndFluidState);
}, [model, props.author]);
const rootStyle: IStyle = {
flexGrow: 1,
position: "relative",
margin: "10px",
borderRadius: "2px",
};
const spaceClass = mergeStyles(rootStyle);
const [, drop] = useDrop(() => ({
accept: 'note',
drop(item: any, monitor) {
const delta = monitor.getDifferenceFromInitialOffset()!;
const left = Math.round(item.left + delta.x);
const top = Math.round(item.top + delta.y);
model.MoveNote(item.id, {
x: left > 0 ? left : 0,
y: top > 0 ? top : 0
})
return undefined;
},
}), [model]);
return (
<div id="NoteSpace" ref={drop} className={spaceClass}>
<ThemeProvider theme={lightTheme}>
{notes.map((note, i) => {
const setPosition = (position: Position) => {
model.MoveNote(note.id, position);
};
const setText = (text: string) => {
model.SetNoteText(note.id, text);
};
const onLike = () => {
model.LikeNote(note.id, props.author);
};
const getLikedUsers = () => {
return model.GetNoteLikedUsers(note.id);
};
const onDelete = () => {
model.DeleteNote(note.id);
};
const onColorChange = (color: string) => {
model.SetNoteColor(note.id, color);
};
return (
<Note
{...note}
id={note.id}
key={note.id}
user={props.author}
text={note.text}
setPosition={setPosition}
onLike={onLike}
getLikedUsers={getLikedUsers}
onDelete={onDelete}
onColorChange={onColorChange}
setText={setText}
/>
);
})}
</ThemeProvider>
</div>
);
}

24
src/view/PersonaList.tsx Normal file
Просмотреть файл

@ -0,0 +1,24 @@
import { IPersonaStyles, List, Persona, PersonaSize } from "@fluentui/react";
import React from "react";
import { User } from "../Types";
export function PersonaList(props: { users: User[] }) {
const personaStyles: Partial<IPersonaStyles> = {
root: {
marginTop: 10,
},
};
const renderPersonaListItem = (item?: User) => {
return (
item && (
<Persona
text={item.userName}
size={PersonaSize.size24}
styles={personaStyles}
></Persona>
)
);
};
return <List items={props.users} onRenderCell={renderPersonaListItem}></List>;
}

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

@ -0,0 +1,29 @@
import { Icon, Label, Stack } from "@fluentui/react";
import React from "react";
import { User } from "../Types";
import { PersonaList } from "./PersonaList";
export type ReactionListCalloutProps = {
label: string;
usersToDisplay: User[];
reactionIconName?: string;
};
export function ReactionListCallout(props: ReactionListCalloutProps) {
return (
<div>
<Stack horizontal tokens={{ childrenGap: 10 }}>
{props.reactionIconName && (
<Icon
iconName={props.reactionIconName}
style={{ fontSize: 15, alignSelf: "center" }}
></Icon>
)}
<Label>Like Reactions</Label>
</Stack>
<PersonaList
users={props.usersToDisplay}
/>
</div>
);
}

80
src/view/Themes.ts Normal file
Просмотреть файл

@ -0,0 +1,80 @@
import { createTheme } from "@fluentui/react";
export type ThemeName = "default" | "dark" | "contrast";
export function normalizeThemeName(theme?: string): ThemeName {
switch (theme) {
case "dark":
return "dark";
case "contrast":
return "contrast";
default:
return "default";
}
}
export function themeNameToTheme(themeName: ThemeName) {
switch (themeName) {
case "default":
return lightTheme;
case "dark":
return darkTheme;
case "contrast":
return darkTheme;
}
}
export const lightTheme = createTheme({
palette: {
themePrimary: "#6264a7",
themeLighterAlt: "#f7f7fb",
themeLighter: "#e1e1f1",
themeLight: "#c8c9e4",
themeTertiary: "#989ac9",
themeSecondary: "#7173b0",
themeDarkAlt: "#585a95",
themeDark: "#4a4c7e",
themeDarker: "#37385d",
neutralLighterAlt: "#faf9f8",
neutralLighter: "#f3f2f1",
neutralLight: "#edebe9",
neutralQuaternaryAlt: "#e1dfdd",
neutralQuaternary: "#d0d0d0",
neutralTertiaryAlt: "#c8c6c4",
neutralTertiary: "#b9b8b7",
neutralSecondary: "#a2a1a0",
neutralPrimaryAlt: "#8b8a89",
neutralPrimary: "#30302f",
neutralDark: "#5e5d5c",
black: "#474645",
white: "#ffffff",
},
});
export const darkTheme = createTheme({
isInverted: true,
palette: {
themePrimary: "#6264a7",
themeLighterAlt: "#040407",
themeLighter: "#10101b",
themeLight: "#1d1e32",
themeTertiary: "#3b3c63",
themeSecondary: "#565892",
themeDarkAlt: "#6e70af",
themeDark: "#8183bb",
themeDarker: "#9ea0cd",
neutralLighterAlt: "#000000",
neutralLighter: "#000000",
neutralLight: "#000000",
neutralQuaternaryAlt: "#000000",
neutralQuaternary: "#000000",
neutralTertiaryAlt: "#000000",
neutralTertiary: "#c8c8c8",
neutralSecondary: "#d0d0d0",
neutralPrimaryAlt: "#dadada",
neutralPrimary: "#ffffff",
neutralDark: "#f4f4f4",
black: "#f8f8f8",
white: "#000000",
},
});

14
src/view/index.css Normal file
Просмотреть файл

@ -0,0 +1,14 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.rootContent {
position: absolute;
top: 0;
left: 0;
width: 100%;
}

10
src/view/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,10 @@
export * from "./BrainstormView";
export * from "./Color";
export * from "./ColorPicker";
export * from "./Header";
export * from "./Note";
export * from "./NoteBody";
export * from "./NoteHeader";
export * from "./NoteSpace";
export * from "./PersonaList";
export * from "./Themes";

26
tsconfig.json Normal file
Просмотреть файл

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