Dwayne Harris 5 years ago
parent
commit
af78c78941
  1. 3
      config/config.json
  2. 160
      package-lock.json
  3. 1
      package.json
  4. 58
      src/actions/apps.ts
  5. 14
      src/actions/authentication.ts
  6. 14
      src/actions/config.ts
  7. 89
      src/actions/groups.ts
  8. 16
      src/components/app-list-item.tsx
  9. 14
      src/components/app.tsx
  10. 93
      src/components/forms/file-field.tsx
  11. 17
      src/components/group-list.tsx
  12. 2
      src/components/navigation-menu.tsx
  13. 32
      src/components/pages/apps.tsx
  14. 8
      src/components/pages/developers.tsx
  15. 124
      src/components/pages/edit-app.tsx
  16. 4
      src/components/pages/groups.tsx
  17. 18
      src/components/pages/self.tsx
  18. 50
      src/components/pages/view-app.tsx
  19. 10
      src/components/user-info.tsx
  20. 4
      src/config.ts
  21. 50
      src/reducers/apps.ts
  22. 20
      src/reducers/config.ts
  23. 77
      src/reducers/groups.ts
  24. 19
      src/selectors/apps.ts
  25. 26
      src/selectors/groups.ts
  26. 2
      src/selectors/index.ts
  27. 4
      src/store/index.ts
  28. 3
      src/styles/app.scss
  29. 5
      src/types/index.ts
  30. 27
      src/types/store.ts

3
config/config.json

@ -1,3 +1,4 @@
{
"apiUrl": "http://localhost:5000"
"apiUrl": "http://localhost:5000",
"blobUrl": "https://flexordev.blob.core.windows.net/media/"
}

160
package-lock.json

@ -4,6 +4,55 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@azure/ms-rest-js": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@azure/ms-rest-js/-/ms-rest-js-2.0.4.tgz",
"integrity": "sha512-nSOPt6st0RtxclYBQV65qXZpvMDqiDQssktvB/SMTAJ5bIytSPtBmlttTTigO5qHvwQcfzzpQE0sMceK+dJ/IQ==",
"requires": {
"@types/node-fetch": "^2.3.7",
"@types/tunnel": "0.0.1",
"abort-controller": "^3.0.0",
"form-data": "^2.5.0",
"node-fetch": "^2.6.0",
"tough-cookie": "^3.0.1",
"tslib": "^1.10.0",
"tunnel": "0.0.6",
"uuid": "^3.3.2",
"xml2js": "^0.4.19"
},
"dependencies": {
"form-data": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
"integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
},
"tough-cookie": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz",
"integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==",
"requires": {
"ip-regex": "^2.1.0",
"psl": "^1.1.28",
"punycode": "^2.1.1"
}
}
}
},
"@azure/storage-blob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-10.5.0.tgz",
"integrity": "sha512-67+0EP7STy9BQgzvN1RgmSvXhxRd044eDgepX7zBp7XslBxz8YGo2cSLm9w5o5Qf1FLCRlwuziRMikaPCLMpVw==",
"requires": {
"@azure/ms-rest-js": "^2.0.0",
"events": "^3.0.0",
"tslib": "^1.9.3"
}
},
"@babel/runtime": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz",
@ -217,8 +266,15 @@
"@types/node": {
"version": "12.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.2.tgz",
"integrity": "sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg==",
"dev": true
"integrity": "sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg=="
},
"@types/node-fetch": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.2.tgz",
"integrity": "sha512-djYYKmdNRSBtL1x4CiE9UJb9yZhwtI1VC+UxZD0psNznrUj80ywsxKlEGAE+QL1qvLjPbfb24VosjkYM6W4RSQ==",
"requires": {
"@types/node": "*"
}
},
"@types/prop-types": {
"version": "15.7.3",
@ -344,6 +400,14 @@
"integrity": "sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ==",
"dev": true
},
"@types/tunnel": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.1.tgz",
"integrity": "sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A==",
"requires": {
"@types/node": "*"
}
},
"@types/uglify-js": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz",
@ -617,6 +681,14 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true
},
"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"
}
},
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
@ -979,8 +1051,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"atob": {
"version": "2.1.2",
@ -1629,7 +1700,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@ -2005,7 +2075,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
"dev": true,
"requires": {
"object-keys": "^1.0.12"
}
@ -2069,8 +2138,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"delegates": {
"version": "1.0.0",
@ -2333,7 +2401,6 @@
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz",
"integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.0",
"function-bind": "^1.1.1",
@ -2347,7 +2414,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz",
"integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==",
"dev": true,
"requires": {
"is-callable": "^1.1.4",
"is-date-object": "^1.0.1",
@ -2397,6 +2463,11 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true
},
"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=="
},
"eventemitter3": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
@ -2406,8 +2477,7 @@
"events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz",
"integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==",
"dev": true
"integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA=="
},
"eventsource": {
"version": "1.0.7",
@ -3488,8 +3558,7 @@
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"gauge": {
"version": "2.7.4",
@ -3738,7 +3807,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
@ -3769,8 +3837,7 @@
"has-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
"integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=",
"dev": true
"integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q="
},
"has-unicode": {
"version": "2.0.1",
@ -4336,8 +4403,7 @@
"ip-regex": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=",
"dev": true
"integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk="
},
"ipaddr.js": {
"version": "1.9.0",
@ -4401,8 +4467,7 @@
"is-callable": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
"dev": true
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA=="
},
"is-data-descriptor": {
"version": "0.1.4",
@ -4427,8 +4492,7 @@
"is-date-object": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
"integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=",
"dev": true
"integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY="
},
"is-descriptor": {
"version": "0.1.6",
@ -4534,7 +4598,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
"integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
"dev": true,
"requires": {
"has": "^1.0.1"
}
@ -4549,7 +4612,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz",
"integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==",
"dev": true,
"requires": {
"has-symbols": "^1.0.0"
}
@ -4951,14 +5013,12 @@
"mime-db": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
"integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==",
"dev": true
"integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
},
"mime-types": {
"version": "2.1.24",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz",
"integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==",
"dev": true,
"requires": {
"mime-db": "1.40.0"
}
@ -5167,6 +5227,11 @@
"lower-case": "^1.1.1"
}
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
@ -5484,8 +5549,7 @@
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
},
"object-visit": {
"version": "1.0.1",
@ -5500,7 +5564,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz",
"integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=",
"dev": true,
"requires": {
"define-properties": "^1.1.2",
"es-abstract": "^1.5.1"
@ -6031,8 +6094,7 @@
"psl": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.3.1.tgz",
"integrity": "sha512-2KLd5fKOdAfShtY2d/8XDWVRnmp3zp40Qt6ge2zBPFARLXOGUf2fHD5eg+TV/5oxBtQKVhjUaKFsAaE4HnwfSA==",
"dev": true
"integrity": "sha512-2KLd5fKOdAfShtY2d/8XDWVRnmp3zp40Qt6ge2zBPFARLXOGUf2fHD5eg+TV/5oxBtQKVhjUaKFsAaE4HnwfSA=="
},
"public-encrypt": {
"version": "4.0.3",
@ -6084,8 +6146,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"qs": {
"version": "6.7.0",
@ -6964,6 +7025,11 @@
}
}
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz",
@ -7938,8 +8004,7 @@
"tslib": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
"dev": true
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ=="
},
"tty-browserify": {
"version": "0.0.0",
@ -7947,6 +8012,11 @@
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=",
"dev": true
},
"tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="
},
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -8178,7 +8248,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
"integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
"dev": true,
"requires": {
"define-properties": "^1.1.2",
"object.getownpropertydescriptors": "^2.0.3"
@ -8815,6 +8884,21 @@
"async-limiter": "~1.0.0"
}
},
"xml2js": {
"version": "0.4.22",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.22.tgz",
"integrity": "sha512-MWTbxAQqclRSTnehWWe5nMKzI3VmJ8ltiJEco8akcC6j3miOhjjfzKum5sId+CWhfxdOs/1xauYr8/ZDBtQiRw==",
"requires": {
"sax": ">=0.6.0",
"util.promisify": "~1.0.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

1
package.json

@ -48,6 +48,7 @@
"webpack-dev-server": "^3.8.2"
},
"dependencies": {
"@azure/storage-blob": "^10.5.0",
"@fortawesome/fontawesome-common-types": "^0.2.25",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-solid-svg-icons": "^5.11.2",

58
src/actions/apps.ts

@ -1,3 +1,5 @@
import { Action } from 'redux'
import { apiFetch } from 'src/api'
import { setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
@ -5,7 +7,57 @@ import { setFieldNotification } from 'src/actions/forms'
import { objectToQuerystring } from 'src/utils'
import { normalize } from 'src/utils/normalization'
import { AppThunkAction, RequestKey, EntityType, App, AvailabilityResponse, NotificationType, AppThunkDispatch }from 'src/types'
import { AppThunkAction, RequestKey, EntityType, App, AvailabilityResponse, NotificationType }from 'src/types'
export interface AppendAppsAction extends Action {
type: 'APPS_APPEND_APPS'
payload: {
items: string[]
continuation?: string
}
}
export interface ClearAppsAction extends Action {
type: 'APPS_CLEAR_APPS'
}
export interface AppendCreatedAppsAction extends Action {
type: 'APPS_APPEND_CREATED_APPS'
payload: {
items: string[]
continuation?: string
}
}
export interface ClearCreatedAppsAction extends Action {
type: 'APPS_CLEAR_CREATED_APPS'
}
export type AppsActions = AppendAppsAction | ClearAppsAction | AppendCreatedAppsAction | ClearCreatedAppsAction
export const appendApps = (apps: string[], continuation?: string): AppendAppsAction => ({
type: 'APPS_APPEND_APPS',
payload: {
items: apps,
continuation,
}
})
export const clearApps = (): ClearAppsAction => ({
type: 'APPS_CLEAR_APPS',
})
export const appendCreatedApps = (apps: string[], continuation?: string): AppendCreatedAppsAction => ({
type: 'APPS_APPEND_CREATED_APPS',
payload: {
items: apps,
continuation,
}
})
export const clearCreatedApps = (): ClearCreatedAppsAction => ({
type: 'APPS_CLEAR_CREATED_APPS',
})
interface AppsResponse {
apps: App[]
@ -23,6 +75,7 @@ export const fetchApps = (sort?: string, continuation?: string): AppThunkAction
const apps = normalize(response.apps, EntityType.App)
dispatch(setEntities(apps.entities))
dispatch(appendApps(apps.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchApps, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchApps, false))
@ -30,7 +83,7 @@ export const fetchApps = (sort?: string, continuation?: string): AppThunkAction
}
}
export const fetchSelfApps = (sort?: string): AppThunkAction => async dispatch => {
export const fetchCreatedApps = (sort?: string): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.FetchSelfApps))
try {
@ -41,6 +94,7 @@ export const fetchSelfApps = (sort?: string): AppThunkAction => async dispatch =
const apps = normalize(response.apps, EntityType.App)
dispatch(setEntities(apps.entities))
dispatch(appendCreatedApps(apps.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchSelfApps, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchSelfApps, false))

14
src/actions/authentication.ts

@ -107,7 +107,17 @@ export const authenticate = (name: string, password: string): AppThunkAction<str
}
}
export const updateSelf = (name: string, about: string, requiresApproval: boolean, privacy: string): AppThunkAction => async dispatch => {
interface UpdateSelfOptions {
name: string
about: string
requiresApproval: boolean
privacy: string
imageUrl: string
coverImageUrl: string
}
export const updateSelf = (options: UpdateSelfOptions): AppThunkAction => async dispatch => {
const { name, about, requiresApproval, privacy, imageUrl, coverImageUrl } = options
dispatch(startRequest(RequestKey.UpdateSelf))
try {
@ -119,6 +129,8 @@ export const updateSelf = (name: string, about: string, requiresApproval: boolea
about,
requiresApproval,
privacy,
imageUrl,
coverImageUrl,
},
})

14
src/actions/config.ts

@ -0,0 +1,14 @@
import { Action } from 'redux'
import { Config } from 'src/types'
export interface SetConfigAction extends Action {
type: 'CONFIG_SET'
payload: Config
}
export type ConfigActions = SetConfigAction
export const setConfig = (config: Config): SetConfigAction => ({
type: 'CONFIG_SET',
payload: config
})

89
src/actions/groups.ts

@ -8,36 +8,78 @@ import { objectToQuerystring } from 'src/utils'
import { normalize } from 'src/utils/normalization'
import { AppThunkAction, Entity, RequestKey, EntityType, User } from 'src/types'
export interface SetGroupsAction extends Action {
type: 'GROUPS_SET_GROUPS'
payload: string[]
export interface AppendGroupsAction extends Action {
type: 'GROUPS_APPEND_GROUPS'
payload: {
items: string[]
continuation?: string
}
}
export interface AppendGroupsAction extends Action {
type: 'GROUPS_APPEND_GROUPS',
payload: string[]
export interface ClearGroupsAction extends Action {
type: 'GROUPS_CLEAR_GROUPS'
}
export interface AppendLogsAction extends Action {
type: 'GROUPS_APPEND_LOGS'
payload: {
items: string[]
continuation?: string
}
}
export interface ClearLogsAction extends Action {
type: 'GROUPS_CLEAR_LOGS'
}
export interface SetContinuationAction extends Action {
type: 'GROUPS_SET_CONTINUATION'
payload: string
export interface AppendInvitationsAction extends Action {
type: 'GROUPS_APPEND_INVITATIONS'
payload: {
items: string[]
continuation?: string
}
}
export type GroupsActions = SetGroupsAction | AppendGroupsAction | SetContinuationAction
export interface ClearInvitationsAction extends Action {
type: 'GROUPS_CLEAR_INVITATIONS'
}
export const setGroups = (groups: string[]): SetGroupsAction => ({
type: 'GROUPS_SET_GROUPS',
payload: groups,
})
export type GroupsActions = AppendGroupsAction | ClearGroupsAction | AppendLogsAction | ClearLogsAction | AppendInvitationsAction | ClearInvitationsAction
export const appendGroups = (groups: string[]): AppendGroupsAction => ({
export const appendGroups = (groups: string[], continuation?: string): AppendGroupsAction => ({
type: 'GROUPS_APPEND_GROUPS',
payload: groups,
payload: {
items: groups,
continuation,
},
})
export const setContinuation = (continuation: string): SetContinuationAction => ({
type: 'GROUPS_SET_CONTINUATION',
payload: continuation,
export const clearGroups = (): ClearGroupsAction => ({
type: 'GROUPS_CLEAR_GROUPS',
})
export const appendLogs = (logs: string[], continuation?: string): AppendLogsAction => ({
type: 'GROUPS_APPEND_LOGS',
payload: {
items: logs,
continuation,
},
})
export const clearLogs = (): ClearLogsAction => ({
type: 'GROUPS_CLEAR_LOGS',
})
export const appendInvitations = (invitations: string[], continuation?: string): AppendInvitationsAction => ({
type: 'GROUPS_APPEND_INVITATIONS',
payload: {
items: invitations,
continuation,
},
})
export const clearInvitations = (): ClearInvitationsAction => ({
type: 'GROUPS_CLEAR_INVITATIONS',
})
export const fetchGroup = (id: string): AppThunkAction => {
@ -74,12 +116,7 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio
const groups = normalize(response.groups, EntityType.Group)
dispatch(setEntities(groups.entities))
dispatch(setGroups(groups.keys))
if (response.continuation) {
dispatch(setContinuation(response.continuation))
}
dispatch(appendGroups(groups.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchGroups, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchGroups, false))
@ -126,6 +163,7 @@ export const fetchLogs = (id: string, continuation?: string): AppThunkAction =>
const users = normalize(response.logs, EntityType.Log)
dispatch(setEntities(users.entities))
dispatch(appendLogs(users.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchGroupLogs, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchGroupLogs, false))
@ -173,6 +211,7 @@ export const fetchInvitations = (id: string): AppThunkAction => async dispatch =
const invitations = normalize(response.invitations, EntityType.Invitation)
dispatch(setEntities(invitations.entities))
dispatch(appendInvitations(invitations.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchInvitations, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchInvitations, false))

16
src/components/app-list-item.tsx

@ -0,0 +1,16 @@
import React, { FC } from 'react'
import { Link } from 'react-router-dom'
import { App } from 'src/types'
interface Props {
app: App
}
const AppListItem: FC<Props> = ({ app }) => (
<div className="app-list-item">
<Link to={`/a/${app.id}`} className="title has-text-primary">{app.name}</Link>
{app.about && <p>{app.about}</p>}
</div>
)
export default AppListItem

14
src/components/app.tsx

@ -1,12 +1,14 @@
import React, { FC, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { BrowserRouter as Router, Route, Switch, Link, useHistory } from 'react-router-dom'
import { BrowserRouter as Router, Route, Switch, Link } from 'react-router-dom'
import { handleApiError } from 'src/api/errors'
import { fetchSelf, setChecked } from 'src/actions/authentication'
import { setConfig } from 'src/actions/config'
import { getFetching } from 'src/selectors'
import { getCollapsed } from 'src/selectors/menu'
import getConfig from 'src/config'
import { LOCAL_STORAGE_ACCESS_TOKEN_KEY } from 'src/constants'
import { AppState, AppThunkDispatch } from 'src/types'
@ -17,8 +19,10 @@ import Spinner from './spinner'
import UserInfo from './user-info'
import About from './pages/about'
import Apps from './pages/apps'
import CreateApp from './pages/create-app'
import Developers from './pages/developers'
import EditApp from './pages/edit-app'
import Group from './pages/group'
import GroupAdmin from './pages/group-admin'
import Groups from './pages/groups'
@ -52,6 +56,8 @@ const App: FC = () => {
} else {
dispatch(setChecked())
}
dispatch(setConfig(await getConfig()))
}
useEffect(() => {
@ -85,6 +91,9 @@ const App: FC = () => {
<Route path="/c/:id">
<Group />
</Route>
<Route path="/a/:id/edit">
<EditApp />
</Route>
<Route path="/a/:id">
<ViewApp />
</Route>
@ -100,6 +109,9 @@ const App: FC = () => {
<Route path="/self/:tab?">
<Self />
</Route>
<Route path="/apps">
<Apps />
</Route>
<Route path="/developers/create">
<CreateApp />
</Route>

93
src/components/forms/file-field.tsx

@ -0,0 +1,93 @@
import React, { FC, ChangeEvent, useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import classNames from 'classnames'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUpload } from '@fortawesome/free-solid-svg-icons'
import { uploadBrowserDataToBlockBlob, Aborter, BlockBlobURL, AnonymousCredential } from '@azure/storage-blob'
import { setFieldValue } from 'src/actions/forms'
import { showNotification } from 'src/actions/notifications'
import { getConfig } from 'src/selectors'
import { getFieldValue } from 'src/selectors/forms'
import { apiFetch } from 'src/api/fetch'
import { AppState, ClassDictionary, SasResponse, NotificationType, Config } from 'src/types'
interface Props {
name: string
label: string
help?: string
}
const CheckboxField: FC<Props> = ({ name, label, help }) => {
const value = useSelector<AppState, boolean>(state => getFieldValue<boolean>(state, name, false))
const config = useSelector<AppState, Config>(getConfig)
const [progress, setProgress] = useState(0)
const [uploading, setUploading] = useState(false)
const dispatch = useDispatch()
const classes: ClassDictionary = {
file: true,
'is-primary': true,
'has-name': !!value,
}
const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const file = event.target.files[0]
if (file.size > 1024 * 1024 * 5) {
dispatch(showNotification(NotificationType.Error, 'Files must be less than 5 MBs'))
return
}
const ext = file.name.substring(file.name.lastIndexOf('.'))
const { sas, id } = await apiFetch<SasResponse>({ path: '/api/sas' })
const filename = `${id}${ext}`
const blobURL = new BlockBlobURL(`${config.blobUrl}${filename}?${sas}`, BlockBlobURL.newPipeline(new AnonymousCredential()))
dispatch(setFieldValue(name, filename))
setUploading(true)
await uploadBrowserDataToBlockBlob(Aborter.none, file, blobURL, {
blockSize: 4 * 1024 * 1024,
progress: p => {
setProgress((p.loadedBytes / file.size) * 100)
}
})
setUploading(false)
}
}
if (uploading) {
return (
<div className="field">
<label className="label">{label}</label>
<progress className="progress is-success" value={progress} max="100">{progress}%</progress>
</div>
)
}
return (
<div className="field">
<label className="label">{label}</label>
<div className={classNames(classes)}>
<label className="file-label">
<input className="file-input" type="file" name={name} onChange={handleChange} />
<span className="file-cta">
<span className="file-icon">
<FontAwesomeIcon icon={faUpload} />
</span>
<span className="file-label">
Upload
</span>
</span>
{value && <span className="file-name">{value}</span>}
</label>
</div>
<p className="help">{help}</p>
</div>
)
}
export default CheckboxField

17
src/components/group-list.tsx

@ -1,17 +0,0 @@
import React, { FC } from 'react'
import { Group } from 'src/types'
import GroupListItem from './group-list-item'
interface Props {
groups: Group[]
}
const GroupList: FC<Props> = ({ groups }) => (
<div>
{groups.map(group => <GroupListItem group={group} />)}
</div>
)
export default GroupList

2
src/components/navigation-menu.tsx

@ -18,7 +18,7 @@ const NavigationMenu: FC = () => (
<FontAwesomeIcon icon={faPaperPlane} />
</span>
&nbsp;
<Link className="has-text-white" to="/">Apps</Link>
<Link className="has-text-white" to="/apps">Apps</Link>
</div>
</div>
)

32
src/components/pages/apps.tsx

@ -0,0 +1,32 @@
import React, { FC, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchApps } from 'src/actions/apps'
import { getApps } from 'src/selectors/apps'
import { setTitle } from 'src/utils'
import { AppState, AppThunkDispatch, App } from 'src/types'
import PageHeader from 'src/components/page-header'
import AppListItem from 'src/components/app-list-item'
const Apps: FC = () => {
const apps = useSelector<AppState, App[]>(getApps)
const dispatch = useDispatch<AppThunkDispatch>()
useEffect(() => {
dispatch(fetchApps())
setTitle('Apps')
}, [])
return (
<div>
<PageHeader title="Apps" />
<div className="main-content">
{apps.map(app => <AppListItem key={app.id} app={app} />)}
</div>
</div>
)
}
export default Apps

8
src/components/pages/developers.tsx

@ -4,20 +4,20 @@ import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlusCircle } from '@fortawesome/free-solid-svg-icons'
import { fetchSelfApps } from 'src/actions/apps'
import { getSelfApps } from 'src/selectors/apps'
import { fetchCreatedApps } from 'src/actions/apps'
import { getCreatedApps } from 'src/selectors/apps'
import { setTitle } from 'src/utils'
import { AppState, App, AppThunkDispatch } from 'src/types'
import PageHeader from 'src/components/page-header'
const Developers: FC = () => {
const apps = useSelector<AppState, App[]>(getSelfApps)
const apps = useSelector<AppState, App[]>(getCreatedApps)
const dispatch = useDispatch<AppThunkDispatch>()
useEffect(() => {
setTitle('Developers')
dispatch(fetchSelfApps())
dispatch(fetchCreatedApps())
}, [])
return (

124
src/components/pages/edit-app.tsx

@ -0,0 +1,124 @@
import React, { FC, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { useParams, useHistory } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faIdCard, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { handleApiError } from 'src/api/errors'
import { fetchApp } from 'src/actions/apps'
import { initForm, initField } from 'src/actions/forms'
import { getAuthenticatedUserId } from 'src/selectors/authentication'
import { getEntity } from 'src/selectors/entities'
import { useAuthenticationCheck, useDeepCompareEffect } from 'src/hooks'
import { setTitle } from 'src/utils'
import { AppState, AppThunkDispatch, EntityType, App } from 'src/types'
import PageHeader from 'src/components/page-header'
import Loading from 'src/components/pages/loading'
import TextField from 'src/components/forms/text-field'
import TextareaField from 'src/components/forms/textarea-field'
import FileField from 'src/components/forms/file-field'
interface Params {
id: string
}
const EditApp: FC = () => {
useAuthenticationCheck()
const { id } = useParams<Params>()
const app = useSelector<AppState, App | undefined>(state => getEntity<App>(state, EntityType.App, id))
const selfId = useSelector<AppState, string | undefined>(getAuthenticatedUserId)
const dispatch = useDispatch<AppThunkDispatch>()
const history = useHistory()
useEffect(() => {
try {
dispatch(fetchApp(id))
} catch (err) {
handleApiError(err, dispatch, history)
}
}, [])
useDeepCompareEffect(() => {
if (app) {
if (app.user.id !== selfId) {
history.push(`/a/${id}`)
}
setTitle(app.name)
dispatch(initForm())
dispatch(initField('name', app.name))
dispatch(initField('about', app.about || ''))
dispatch(initField('websiteUrl', app.websiteUrl || ''))
dispatch(initField('companyName', app.companyName || ''))
dispatch(initField('version', ''))
dispatch(initField('composerUrl', app.composerUrl || ''))
dispatch(initField('rendererUrl', app.rendererUrl || ''))
dispatch(initField('image', app.imageUrl || ''))
dispatch(initField('coverImage', app.coverImageUrl || ''))
dispatch(initField('iconImage', app.iconImageUrl || ''))
}
}, [app])
if (!app) return <Loading />
const handleUpdate = () => {
}
return (
<div>
<PageHeader title={app.name} />
<div className="main-content">
<div className="centered-content">
<div className="field">
<label className="label">ID</label>
<div className="control has-icons-left">
<input className="input" type="text" value={app.id} readOnly />
<span className="icon is-small is-left">
<FontAwesomeIcon icon={faIdCard} />
</span>
</div>
</div>
<br />
<TextField name="name" label="Name" placeholder="App Name" />
<br />
<TextareaField name="about" label="About" placeholder="Description of this app" />
<br />
<TextField name="websiteUrl" label="Website" placeholder="Website URL (optional)" />
<br />
<TextField name="companyName" label="Company" placeholder="Your company or organization (optional)" />
<br />
<FileField name="image" label="Image" />
<br />
<FileField name="coverImage" label="Cover Image" />
<br />
<FileField name="iconImage" label="Icon Image" />
<br />
<TextField name="version" label="Version" placeholder="Current Version of the app (ex: 0.0.1beta5)" />
<br /><hr />
<TextField name="composerUrl" label="Composer URL" placeholder="URL for the composer web page" />
<br />
<TextField name="rendererUrl" label="Renderer URL" placeholder="URL for the renderer template" />
<br /><br />
<button className="button is-primary" onClick={() => handleUpdate()}>
<span className="icon is-small">
<FontAwesomeIcon icon={faCheckCircle} />
</span>
<span>Save</span>
</button>
</div>
</div>
</div>
)
}
export default EditApp

4
src/components/pages/groups.tsx

@ -10,7 +10,7 @@ import { setTitle } from 'src/utils'
import { AppState, AppThunkDispatch, Group } from 'src/types'
import PageHeader from 'src/components/page-header'
import GroupList from 'src/components/group-list'
import GroupListItem from 'src/components/group-list-item'
const Groups: FC = () => {
const groups = useSelector<AppState, Group[]>(getGroups)
@ -26,7 +26,7 @@ const Groups: FC = () => {
<PageHeader title="Communities" />
<div className="main-content">
<GroupList groups={groups} />
{groups.map(group => <GroupListItem group={group} />)}
<hr />
<p className="has-text-centered">

18
src/components/pages/self.tsx

@ -22,6 +22,7 @@ import TextField from 'src/components/forms/text-field'
import TextareaField from 'src/components/forms/textarea-field'
import SelectField from 'src/components/forms/select-field'
import CheckboxField from 'src/components/forms/checkbox-field'
import FileField from 'src/components/forms/file-field'
interface Params {
tab: string
@ -54,9 +55,18 @@ const Self: FC = () => {
const about = valueFromForm<string>(form, 'about', '')
const requiresApproval = valueFromForm<boolean>(form, 'requiresApproval', true)
const privacy = valueFromForm<string>(form, 'privacy', 'public')
const imageUrl = valueFromForm<string>(form, 'image', '')
const coverImageUrl = valueFromForm<string>(form, 'coverImage', '')
try {
dispatch(updateSelf(name, about, requiresApproval, privacy))
dispatch(updateSelf({
name,
about,
requiresApproval,
privacy,
imageUrl,
coverImageUrl,
}))
} catch (err) {
handleApiError(err, dispatch, history)
}
@ -71,6 +81,8 @@ const Self: FC = () => {
dispatch(initField('about', user.about || ''))
dispatch(initField('requiresApproval', user.requiresApproval))
dispatch(initField('privacy', user.privacy))
dispatch(initField('image', user.imageUrl || ''))
dispatch(initField('coverImage', user.coverImageUrl || ''))
}
}, [user])
@ -138,6 +150,10 @@ const Self: FC = () => {
<br />
<SelectField name="privacy" label="Privacy" options={PRIVACY_OPTIONS} icon={faUserShield} />
<br />
<FileField name="image" label="Image" />
<br />
<FileField name="coverImage" label="Cover Image" />
<br />
<CheckboxField name="requiresApproval">
You must approve each Subscription request from other users.
</CheckboxField>

50
src/components/pages/view-app.tsx

@ -1,8 +1,10 @@
import React, { FC, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { useParams, useHistory } from 'react-router-dom'
import { Link, useParams, useHistory } from 'react-router-dom'
import classNames from 'classnames'
import moment from 'moment'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlusSquare, faMinusSquare } from '@fortawesome/free-solid-svg-icons'
import { handleApiError } from 'src/api/errors'
import { fetchApp, installApp, uninstallApp } from 'src/actions/apps'
@ -46,32 +48,43 @@ const ViewApp: FC = () => {
const isCreator = app.user.id === selfId
const renderButton = () => {
const classes: ClassDictionary = {
'button': true,
'is-danger': app.installed,
'is-success': !app.installed,
'is-large': true,
'is-outlined': true,
'is-loading': fetching,
}
if (app.installed) {
const handleClick = async () => {
await dispatch(uninstallApp(id))
await dispatch(fetchApp(id))
}
const classes: ClassDictionary = {
'button': true,
'is-danger': true,
'is-loading': fetching,
}
return <button className={classNames(classes)} onClick={() => handleClick()}>Uninstall</button>
return (
<button className={classNames(classes)} onClick={() => handleClick()}>
<span className="icon is-small">
<FontAwesomeIcon icon={faMinusSquare} />
</span>
<span>Uninstall</span>
</button>
)
} else {
const handleClick = async () => {
await dispatch(installApp(id))
await dispatch(fetchApp(id))
}
const classes: ClassDictionary = {
'button': true,
'is-success': true,
'is-loading': fetching,
}
return <button className={classNames(classes)} onClick={() => handleClick()}>Install</button>
return (
<button className={classNames(classes)} onClick={() => handleClick()}>
<span className="icon is-small">
<FontAwesomeIcon icon={faPlusSquare} />
</span>
<span>Install</span>
</button>
)
}
}
@ -117,6 +130,13 @@ const ViewApp: FC = () => {
<br />
{renderButton()}
{isCreator &&
<div>
<hr />
<Link className="button is-warning" to={`/a/${id}/edit`}>Edit App</Link>
</div>
}
</div>
</div>
</div>

10
src/components/user-info.tsx

@ -2,13 +2,15 @@ import React, { FC } from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { getConfig } from 'src/selectors'
import { getAuthenticatedUser } from 'src/selectors/authentication'
import { AppState, User } from 'src/types'
import { AppState, User, Config } from 'src/types'
const UserInfo: FC = () => {
const user = useSelector<AppState, User | undefined>(getAuthenticatedUser)
const config = useSelector<AppState, Config>(getConfig)
const hasAvatar = user && user.imageUrl
const imageUrl = hasAvatar ? user!.imageUrl : undefined
const imageUrl = hasAvatar ? `${config.blobUrl}${user!.imageUrl}` : undefined
const name = () => {
if (!user) return <span></span>
@ -60,10 +62,10 @@ const UserInfo: FC = () => {
return (
<article id="user-info" className="media has-background-black">
{hasAvatar &&
{imageUrl &&
<figure className="media-left">
<p className="image is-64x64">
<img src={imageUrl} />
<img src={imageUrl} style={{ width: 64 }} />
</p>
</figure>
}

4
src/config.ts

@ -1,6 +1,4 @@
interface Config {
apiUrl: string
}
import { Config } from './types'
declare global {
interface Window {

50
src/reducers/apps.ts

@ -0,0 +1,50 @@
import { Reducer } from 'redux'
import { AppsActions } from '../actions/apps'
import { AppsState } from '../types'
const initialState: AppsState = {
items: [],
continuation: undefined,
created: {
items: [],
continuation: undefined
}
}
const reducer: Reducer<AppsState, AppsActions> = (state = initialState, action) => {
switch (action.type) {
case 'APPS_APPEND_APPS':
return {
...state,
items: action.payload.items,
continuation: action.payload.continuation,
}
case 'APPS_CLEAR_APPS':
return {
...state,
items: [],
continuation: undefined,
}
case 'APPS_APPEND_CREATED_APPS':
return {
...state,
created: {
items: action.payload.items,
continuation: action.payload.continuation,
},
}
case 'APPS_CLEAR_CREATED_APPS':
return {
...state,
created: {
items: [],
continuation: undefined,
},
}
default:
return state
}
}
export default reducer

20
src/reducers/config.ts

@ -0,0 +1,20 @@
import { Reducer } from 'redux'
import { ConfigActions } from '../actions/config'
import { ConfigState } from '../types'
const initialState: ConfigState = {
apiUrl: '',
blobUrl: '',
}
const reducer: Reducer<ConfigState, ConfigActions> = (state = initialState, action) => {
switch (action.type) {
case 'CONFIG_SET':
return action.payload
default:
return state
}
}
export default reducer

77
src/reducers/groups.ts

@ -4,29 +4,86 @@ import { GroupsActions } from '../actions/groups'
import { GroupsState } from '../types'
const initialState: GroupsState = {
groups: [],
items: [],
continuation: undefined,
admin: {
invitations: {
items: [],
continuation: undefined,
},
logs: {
items: [],
continuation: undefined,
}
}
}
const reducer: Reducer<GroupsState, GroupsActions> = (state = initialState, action) => {
switch (action.type) {
case 'GROUPS_SET_GROUPS':
case 'GROUPS_APPEND_GROUPS':
return {
...state,
groups: action.payload,
items: [
...state.items,
...action.payload.items,
],
continuation: action.payload.continuation,
}
case 'GROUPS_APPEND_GROUPS':
case 'GROUPS_CLEAR_GROUPS':
return {
...state,
groups: [
...state.groups,
...action.payload,
],
items: [],
continuation: undefined,
}
case 'GROUPS_APPEND_LOGS':
return {
...state,
admin: {
...state.admin,
logs: {
items: [
...state.admin.logs.items,
...action.payload.items,
],
continuation: action.payload.continuation,
},
},
}
case 'GROUPS_CLEAR_LOGS':
return {
...state,
admin: {
...state.admin,
logs: {
items: [],
continuation: undefined,
},
},
}
case 'GROUPS_SET_CONTINUATION':
case 'GROUPS_APPEND_INVITATIONS':
return {
...state,
admin: {
...state.admin,
invitations: {
items: [
...state.admin.logs.items,
...action.payload.items,
],
continuation: action.payload.continuation,
},
},
}
case 'GROUPS_CLEAR_INVITATIONS':
return {
...state,
continuation: action.payload,
admin: {
...state.admin,
invitations: {
items: [],
continuation: undefined,
},
},
}
default:
return state

19
src/selectors/apps.ts

@ -1,18 +1,5 @@
import { createSelector } from 'reselect'
import { denormalize } from 'src/utils/normalization'
import { getEntityStore } from './entities'
import { getAuthenticatedUserId } from './authentication'
import { EntityType, App } from 'src/types'
export const getSelfApps = createSelector(
[getEntityStore, getAuthenticatedUserId],
(entities, userId) => {
const apps = entities[EntityType.App]
if (!apps) return []
import { AppState, EntityType, App } from 'src/types'
return denormalize(Object.values(apps).filter(app => app.userId === userId).map(user => user.id), EntityType.User, entities) as App[]
}
)
export const getApps = (state: AppState) => denormalize(state.apps.items, EntityType.App, state.entities) as App[]
export const getCreatedApps = (state: AppState) => denormalize(state.apps.created.items, EntityType.App, state.entities) as App[]

26
src/selectors/groups.ts

@ -1,15 +1,9 @@
import { createSelector } from 'reselect'
import { getEntityStore } from './entities'
import { denormalize } from 'src/utils/normalization'
import { AppState, Group, User, EntityType, GroupLog, Invitation } from 'src/types'
export const getGroupIds = (state: AppState) => state.groups.groups
export const getGroups = createSelector(
[getEntityStore, getGroupIds],
(entities, groups) => denormalize(groups, EntityType.Group, entities) as Group[]
)
export const getGroups = (state: AppState) => denormalize(state.groups.items, EntityType.Group, state.entities) as Group[]
export const getLogs = (state: AppState) => denormalize(state.groups.admin.logs.items, EntityType.Log, state.entities) as GroupLog[]
export const getInvitations = (state: AppState) => denormalize(state.groups.admin.invitations.items, EntityType.Invitation, state.entities) as Invitation[]
export const getGroupMembers = (state: AppState, group: string) => {
const users = state.entities[EntityType.User]
@ -17,17 +11,3 @@ export const getGroupMembers = (state: AppState, group: string) => {
return denormalize(Object.values(users).filter(user => user.group === group).map(user => user.id), EntityType.User, state.entities) as User[]
}
export const getLogs = (state: AppState) => {
const logs = state.entities[EntityType.Log]
if (!logs) return []
return denormalize(Object.keys(logs), EntityType.Log, state.entities) as GroupLog[]
}
export const getInvitations = (state: AppState) => {
const invitations = state.entities[EntityType.Invitation]
if (!invitations) return []
return denormalize(Object.keys(invitations), EntityType.Invitation, state.entities) as Invitation[]
}

2
src/selectors/index.ts

@ -9,3 +9,5 @@ export const getRequests = (state: AppState) => state.requests
export const getFetching = createSelector(getRequests, requests => {
return values(requests).reduce((fetching, request) => fetching || request.fetching, false)
})
export const getConfig = (state: AppState) => state.config

4
src/store/index.ts

@ -1,7 +1,9 @@
import { createStore, combineReducers, applyMiddleware } from 'redux'
import { AppState } from '../types'
import apps from '../reducers/apps'
import authentication from '../reducers/authentication'
import config from '../reducers/config'
import entities from '../reducers/entities'
import forms from '../reducers/forms'
import groups from '../reducers/groups'
@ -15,7 +17,9 @@ import thunk from 'redux-thunk'
const store = createStore(
combineReducers<AppState>({
apps,
authentication,
config,
entities,
forms,
groups,

3
src/styles/app.scss

@ -30,6 +30,7 @@ $body-size: 14px;
@import "../../node_modules/bulma/sass/elements/table.sass";
@import "../../node_modules/bulma/sass/elements/tag.sass";
@import "../../node_modules/bulma/sass/elements/title.sass";
@import "../../node_modules/bulma/sass/elements/progress.sass";
@import "../../node_modules/bulma/sass/layout/hero.sass";
@import "../../node_modules/bulma/sass/components/level.sass";
@import "../../node_modules/bulma/sass/components/media.sass";
@ -93,7 +94,7 @@ div#notification-container {
width: 40%;
}
div.group-list-item {
div.group-list-item, div.app-list-item {
background-color: $white;
border-radius: 15px;
margin: 10px 0px;

5
src/types/index.ts

@ -25,6 +25,11 @@ export interface AvailabilityResponse {
available: boolean
}
export interface SasResponse {
sas: string
id: string
}
export * from './entities'
export * from './store'

27
src/types/store.ts

@ -1,6 +1,11 @@
import { EntityStore } from './entities'
export interface Config {
apiUrl: string
blobUrl: string
}
export enum NotificationType {
Info = 'info',
Success = 'success',
@ -78,26 +83,42 @@ export interface Form {
[name: string]: FormField
}
export interface EntityList {
items: string[]
continuation?: string
}
export interface FormsState {
form: Form
notification?: FormNotification
}
export interface GroupsState {
groups: string[]
continuation?: string
export interface GroupsAdminState {
invitations: EntityList
logs: EntityList
}
export type GroupsState = EntityList & {
admin: GroupsAdminState
}
export interface RegistrationState {
step: number
}
export type AppsState = EntityList & {
created: EntityList
}
export type ConfigState = Config
export type RequestsState = APIRequestCollection
export type NotificationsState = Notification[]
export type EntitiesState = EntityStore
export interface AppState {
authentication: AuthenticationState
apps: AppsState
config: ConfigState
entities: EntitiesState
forms: FormsState
groups: GroupsState

Loading…
Cancel
Save