Initial check-in
This commit is contained in:
Коммит
08f3f03d73
|
@ -0,0 +1,4 @@
|
|||
REACT_APP_TENANT_ID=
|
||||
REACT_APP_PRIMARY_KEY=
|
||||
REACT_APP_ORDERER_ENDPOINT=
|
||||
REACT_APP_STORAGE_ENDPOINT=
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"ms-azuretools.vscode-azurefunctions"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Attach to Node Functions",
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"port": 9229,
|
||||
"preLaunchTask": "func: host start"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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)"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"ms-azuretools.vscode-azurefunctions"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Attach to Node Functions",
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"port": 9229,
|
||||
"preLaunchTask": "func: host start"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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)"
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 260 KiB |
Двоичный файл не отображается.
|
@ -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": []
|
||||
}
|
|
@ -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!
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -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[];
|
||||
},
|
||||
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
};
|
|
@ -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 });
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
};
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -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;
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import { User } from './Types';
|
||||
|
||||
const UserContext = React.createContext({
|
||||
userName: '',
|
||||
userId: ''
|
||||
} as User);
|
||||
|
||||
export default UserContext;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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" },
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
});
|
|
@ -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%;
|
||||
}
|
|
@ -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";
|
|
@ -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"
|
||||
]
|
||||
}
|
Загрузка…
Ссылка в новой задаче