Basic storage backend with diff/patch synchronization
This commit is contained in:
parent
1ac485abf3
commit
a9c24051b0
|
@ -82,6 +82,11 @@
|
||||||
"integrity": "sha512-q95SP4FdkmF0CwO0F2q0H6ZgudsApaY/yCtAQNRn1gduef5fGpyEphzy0YCq/N0UFvDSnLg5V8jFK/YGXlDiCw==",
|
"integrity": "sha512-q95SP4FdkmF0CwO0F2q0H6ZgudsApaY/yCtAQNRn1gduef5fGpyEphzy0YCq/N0UFvDSnLg5V8jFK/YGXlDiCw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/json-merge-patch": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/json-merge-patch/-/json-merge-patch-0.0.4.tgz",
|
||||||
|
"integrity": "sha1-pSgtqWkKgSpiEoo0cIr0dqMI2UE="
|
||||||
|
},
|
||||||
"@types/minimatch": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||||
|
@ -1729,7 +1734,6 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
|
||||||
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
|
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"is-arguments": "^1.0.4",
|
"is-arguments": "^1.0.4",
|
||||||
"is-date-object": "^1.0.1",
|
"is-date-object": "^1.0.1",
|
||||||
|
@ -1753,7 +1757,6 @@
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
|
||||||
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
|
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"object-keys": "^1.0.12"
|
"object-keys": "^1.0.12"
|
||||||
}
|
}
|
||||||
|
@ -2041,7 +2044,6 @@
|
||||||
"version": "1.17.5",
|
"version": "1.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"es-to-primitive": "^1.2.1",
|
"es-to-primitive": "^1.2.1",
|
||||||
"function-bind": "^1.1.1",
|
"function-bind": "^1.1.1",
|
||||||
|
@ -2060,7 +2062,6 @@
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
|
||||||
"integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
|
"integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"is-callable": "^1.1.4",
|
"is-callable": "^1.1.4",
|
||||||
"is-date-object": "^1.0.1",
|
"is-date-object": "^1.0.1",
|
||||||
|
@ -2392,11 +2393,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
|
||||||
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
|
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
|
||||||
},
|
},
|
||||||
"fast-json-patch": {
|
|
||||||
"version": "3.0.0-1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.0.0-1.tgz",
|
|
||||||
"integrity": "sha512-6pdFb07cknxvPzCeLsFHStEy+MysPJPgZQ9LbQ/2O67unQF93SNqfdSqnPPl71YMHX+AD8gbl7iuoGFzHEdDuw=="
|
|
||||||
},
|
|
||||||
"fast-json-stable-stringify": {
|
"fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
|
@ -3356,8 +3352,7 @@
|
||||||
"function-bind": {
|
"function-bind": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
|
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"get-caller-file": {
|
"get-caller-file": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
|
@ -3487,7 +3482,6 @@
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"function-bind": "^1.1.1"
|
"function-bind": "^1.1.1"
|
||||||
}
|
}
|
||||||
|
@ -3510,8 +3504,7 @@
|
||||||
"has-symbols": {
|
"has-symbols": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
|
||||||
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
|
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"has-value": {
|
"has-value": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
@ -4018,8 +4011,7 @@
|
||||||
"is-arguments": {
|
"is-arguments": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
|
||||||
"integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==",
|
"integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"is-binary-path": {
|
"is-binary-path": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
@ -4039,8 +4031,7 @@
|
||||||
"is-callable": {
|
"is-callable": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==",
|
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"is-data-descriptor": {
|
"is-data-descriptor": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
|
@ -4065,8 +4056,7 @@
|
||||||
"is-date-object": {
|
"is-date-object": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
|
||||||
"integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==",
|
"integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"is-descriptor": {
|
"is-descriptor": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
|
@ -4163,7 +4153,6 @@
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"has": "^1.0.3"
|
"has": "^1.0.3"
|
||||||
}
|
}
|
||||||
|
@ -4178,7 +4167,6 @@
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
|
||||||
"integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
|
"integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"has-symbols": "^1.0.1"
|
"has-symbols": "^1.0.1"
|
||||||
}
|
}
|
||||||
|
@ -4219,6 +4207,14 @@
|
||||||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
|
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"json-merge-patch": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-merge-patch/-/json-merge-patch-0.2.3.tgz",
|
||||||
|
"integrity": "sha1-+ixrWvh9p3uuKWalidUuI+2B/kA=",
|
||||||
|
"requires": {
|
||||||
|
"deep-equal": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"json-parse-better-errors": {
|
"json-parse-better-errors": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
|
||||||
|
@ -4777,14 +4773,12 @@
|
||||||
"object-inspect": {
|
"object-inspect": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
|
||||||
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==",
|
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"object-is": {
|
"object-is": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
|
||||||
"integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
|
"integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.5"
|
"es-abstract": "^1.17.5"
|
||||||
|
@ -4793,8 +4787,7 @@
|
||||||
"object-keys": {
|
"object-keys": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"object-path": {
|
"object-path": {
|
||||||
"version": "0.11.4",
|
"version": "0.11.4",
|
||||||
|
@ -4815,7 +4808,6 @@
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
|
||||||
"integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
|
"integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.2",
|
"define-properties": "^1.1.2",
|
||||||
"function-bind": "^1.1.1",
|
"function-bind": "^1.1.1",
|
||||||
|
@ -5614,7 +5606,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
|
||||||
"integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
|
"integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.0-next.1"
|
"es-abstract": "^1.17.0-next.1"
|
||||||
|
@ -6466,7 +6457,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
|
||||||
"integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
|
"integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.5"
|
"es-abstract": "^1.17.5"
|
||||||
|
@ -6476,7 +6466,6 @@
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.5",
|
"es-abstract": "^1.17.5",
|
||||||
|
@ -6487,7 +6476,6 @@
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.5",
|
"es-abstract": "^1.17.5",
|
||||||
|
@ -6498,7 +6486,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
|
||||||
"integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
|
"integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"define-properties": "^1.1.3",
|
"define-properties": "^1.1.3",
|
||||||
"es-abstract": "^1.17.5"
|
"es-abstract": "^1.17.5"
|
||||||
|
|
|
@ -5,10 +5,11 @@
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bs58": "^4.0.1",
|
"@types/bs58": "^4.0.1",
|
||||||
|
"@types/json-merge-patch": "0.0.4",
|
||||||
"bs58": "^4.0.1",
|
"bs58": "^4.0.1",
|
||||||
"bulma": "^0.8.2",
|
"bulma": "^0.8.2",
|
||||||
"bulma-switch": "^2.0.0",
|
"bulma-switch": "^2.0.0",
|
||||||
"fast-json-patch": "^3.0.0-1",
|
"json-merge-patch": "^0.2.3",
|
||||||
"preact": "^10.4.1",
|
"preact": "^10.4.1",
|
||||||
"preact-markup": "^1.6.0",
|
"preact-markup": "^1.6.0",
|
||||||
"preact-render-to-string": "^5.1.6",
|
"preact-render-to-string": "^5.1.6",
|
||||||
|
|
|
@ -15,9 +15,14 @@ const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value,
|
||||||
const [ editMode, setEditMode ] = useState(false);
|
const [ editMode, setEditMode ] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange) onChange(internalValue);
|
if (internalValue === value) return;
|
||||||
|
if (onChange) onChange(internalValue);
|
||||||
}, [internalValue]);
|
}, [internalValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(value);
|
||||||
|
}, [value])
|
||||||
|
|
||||||
const onEditIconClick = () => {
|
const onEditIconClick = () => {
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
export default function useDebounce(func: Function, delay: number) {
|
||||||
|
const [id, setId] = useState<number|null>(null)
|
||||||
|
return useMemo(
|
||||||
|
(...args) => {
|
||||||
|
if (id) {
|
||||||
|
clearTimeout(id)
|
||||||
|
} else {
|
||||||
|
setId(
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setId(null)
|
||||||
|
func(...args)
|
||||||
|
}, delay)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [func]
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,33 +3,33 @@ import { useState, useEffect } from "preact/hooks";
|
||||||
import { getProjectWeightedMean, getProjectStandardDeviation } from "../util/stat";
|
import { getProjectWeightedMean, getProjectStandardDeviation } from "../util/stat";
|
||||||
|
|
||||||
export interface Estimation {
|
export interface Estimation {
|
||||||
e: number
|
e: number
|
||||||
sd: number
|
sd: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjetEstimations {
|
export interface ProjetEstimations {
|
||||||
p99: Estimation
|
p99: Estimation
|
||||||
p90: Estimation
|
p90: Estimation
|
||||||
p68: Estimation
|
p68: Estimation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectEstimations(p :Project): ProjetEstimations {
|
export function useProjectEstimations(p :Project): ProjetEstimations {
|
||||||
const [ estimations, setEstimations ] = useState({
|
const [ estimations, setEstimations ] = useState({
|
||||||
p99: { e: 0, sd: 0 },
|
p99: { e: 0, sd: 0 },
|
||||||
p90: { e: 0, sd: 0 },
|
p90: { e: 0, sd: 0 },
|
||||||
p68: { e: 0, sd: 0 },
|
p68: { e: 0, sd: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const projectWeightedMean = getProjectWeightedMean(p)
|
const projectWeightedMean = getProjectWeightedMean(p)
|
||||||
const projectStandardDeviation = getProjectStandardDeviation(p);
|
const projectStandardDeviation = getProjectStandardDeviation(p);
|
||||||
|
|
||||||
setEstimations({
|
setEstimations({
|
||||||
p99: { e: projectWeightedMean, sd: (projectStandardDeviation * 3) },
|
p99: { e: projectWeightedMean, sd: (projectStandardDeviation * 3) },
|
||||||
p90: { e: projectWeightedMean, sd: (projectStandardDeviation * 1.645) },
|
p90: { e: projectWeightedMean, sd: (projectStandardDeviation * 1.645) },
|
||||||
p68: { e: projectWeightedMean, sd: (projectStandardDeviation) },
|
p68: { e: projectWeightedMean, sd: (projectStandardDeviation) },
|
||||||
})
|
})
|
||||||
}, [p.tasks]);
|
}, [p.tasks]);
|
||||||
|
|
||||||
return estimations;
|
return estimations;
|
||||||
}
|
}
|
|
@ -1,301 +1,325 @@
|
||||||
import { Project } from "../models/project";
|
import { Project } from "../models/project";
|
||||||
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
|
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
|
||||||
import { useReducer } from "preact/hooks";
|
import { useReducer } from "preact/hooks";
|
||||||
|
import { generate as diff } from "json-merge-patch";
|
||||||
|
import { applyPatch } from "../util/patch";
|
||||||
|
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectReducerActions =
|
export type ProjectReducerActions =
|
||||||
AddTask |
|
AddTask |
|
||||||
RemoveTask |
|
RemoveTask |
|
||||||
UpdateTaskEstimation |
|
UpdateTaskEstimation |
|
||||||
UpdateProjectLabel |
|
UpdateProjectLabel |
|
||||||
UpdateTaskLabel |
|
UpdateTaskLabel |
|
||||||
UpdateParam |
|
UpdateParam |
|
||||||
UpdateTaskCategoryLabel |
|
UpdateTaskCategoryLabel |
|
||||||
UpdateTaskCategoryCost |
|
UpdateTaskCategoryCost |
|
||||||
AddTaskCategory |
|
AddTaskCategory |
|
||||||
RemoveTaskCategory
|
RemoveTaskCategory |
|
||||||
|
PatchProject
|
||||||
|
|
||||||
export function useProjectReducer(project: Project) {
|
export function useProjectReducer(project: Project) {
|
||||||
return useReducer(projectReducer, project);
|
return useReducer(projectReducer, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
||||||
console.log(action);
|
console.log(action);
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ADD_TASK:
|
case ADD_TASK:
|
||||||
return handleAddTask(project, action as AddTask);
|
return handleAddTask(project, action as AddTask);
|
||||||
|
|
||||||
case REMOVE_TASK:
|
case REMOVE_TASK:
|
||||||
return handleRemoveTask(project, action as RemoveTask);
|
return handleRemoveTask(project, action as RemoveTask);
|
||||||
|
|
||||||
case UPDATE_TASK_ESTIMATION:
|
case UPDATE_TASK_ESTIMATION:
|
||||||
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
||||||
|
|
||||||
case UPDATE_PROJECT_LABEL:
|
case UPDATE_PROJECT_LABEL:
|
||||||
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
|
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
|
||||||
|
|
||||||
case UPDATE_TASK_LABEL:
|
case UPDATE_TASK_LABEL:
|
||||||
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
|
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
|
||||||
|
|
||||||
case UPDATE_PARAM:
|
case UPDATE_PARAM:
|
||||||
return handleUpdateParam(project, action as UpdateParam);
|
return handleUpdateParam(project, action as UpdateParam);
|
||||||
|
|
||||||
case ADD_TASK_CATEGORY:
|
case ADD_TASK_CATEGORY:
|
||||||
return handleAddTaskCategory(project, action as AddTaskCategory);
|
return handleAddTaskCategory(project, action as AddTaskCategory);
|
||||||
|
|
||||||
case REMOVE_TASK_CATEGORY:
|
case REMOVE_TASK_CATEGORY:
|
||||||
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
|
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
|
||||||
|
|
||||||
case UPDATE_TASK_CATEGORY_LABEL:
|
case UPDATE_TASK_CATEGORY_LABEL:
|
||||||
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
|
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
|
||||||
|
|
||||||
case UPDATE_TASK_CATEGORY_COST:
|
case UPDATE_TASK_CATEGORY_COST:
|
||||||
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
|
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
|
||||||
}
|
|
||||||
|
|
||||||
return project;
|
case PATCH_PROJECT:
|
||||||
|
return handlePatchProject(project, action as PatchProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddTask extends Action {
|
export interface AddTask extends Action {
|
||||||
task: Task
|
task: Task
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ADD_TASK = "ADD_TASK";
|
export const ADD_TASK = "ADD_TASK";
|
||||||
|
|
||||||
export function addTask(task: Task): AddTask {
|
export function addTask(task: Task): AddTask {
|
||||||
return { type: ADD_TASK, task };
|
return { type: ADD_TASK, task };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAddTask(project: Project, action: AddTask): Project {
|
export function handleAddTask(project: Project, action: AddTask): Project {
|
||||||
const task = { ...action.task };
|
const task = { ...action.task };
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks: {
|
tasks: {
|
||||||
...project.tasks,
|
...project.tasks,
|
||||||
[task.id]: task,
|
[task.id]: task,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveTask extends Action {
|
export interface RemoveTask extends Action {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REMOVE_TASK = "REMOVE_TASK";
|
export const REMOVE_TASK = "REMOVE_TASK";
|
||||||
|
|
||||||
export function removeTask(id: TaskID): RemoveTask {
|
export function removeTask(id: TaskID): RemoveTask {
|
||||||
return { type: REMOVE_TASK, id };
|
return { type: REMOVE_TASK, id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
|
export function handleRemoveTask(project: Project, action: RemoveTask): Project {
|
||||||
const tasks = { ...project.tasks };
|
const tasks = { ...project.tasks };
|
||||||
delete tasks[action.id];
|
delete tasks[action.id];
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks
|
tasks
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskEstimation extends Action {
|
export interface UpdateTaskEstimation extends Action {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
confidence: string
|
confidence: string
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
|
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
|
||||||
|
|
||||||
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
|
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
|
||||||
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
|
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
|
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
|
||||||
const estimations = {
|
const estimations = {
|
||||||
...project.tasks[action.id].estimations,
|
...project.tasks[action.id].estimations,
|
||||||
[action.confidence]: action.value
|
[action.confidence]: action.value
|
||||||
};
|
};
|
||||||
|
|
||||||
if (estimations.likely < estimations.optimistic) {
|
if (estimations.likely < estimations.optimistic) {
|
||||||
estimations.likely = estimations.optimistic;
|
estimations.likely = estimations.optimistic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimations.pessimistic < estimations.likely) {
|
||||||
|
estimations.pessimistic = estimations.likely;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
tasks: {
|
||||||
|
...project.tasks,
|
||||||
|
[action.id]: {
|
||||||
|
...project.tasks[action.id],
|
||||||
|
estimations: estimations,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
if (estimations.pessimistic < estimations.likely) {
|
|
||||||
estimations.pessimistic = estimations.likely;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
tasks: {
|
|
||||||
...project.tasks,
|
|
||||||
[action.id]: {
|
|
||||||
...project.tasks[action.id],
|
|
||||||
estimations: estimations,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface UpdateProjectLabel extends Action {
|
export interface UpdateProjectLabel extends Action {
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
|
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL";
|
||||||
|
|
||||||
export function updateProjectLabel(label: string): UpdateProjectLabel {
|
export function updateProjectLabel(label: string): UpdateProjectLabel {
|
||||||
return { type: UPDATE_PROJECT_LABEL, label };
|
return { type: UPDATE_PROJECT_LABEL, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
|
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
label: action.label
|
label: action.label
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskLabel extends Action {
|
export interface UpdateTaskLabel extends Action {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
|
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
|
||||||
|
|
||||||
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
|
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel {
|
||||||
return { type: UPDATE_TASK_LABEL, id, label };
|
return { type: UPDATE_TASK_LABEL, id, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
|
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tasks: {
|
tasks: {
|
||||||
...project.tasks,
|
...project.tasks,
|
||||||
[action.id]: {
|
[action.id]: {
|
||||||
...project.tasks[action.id],
|
...project.tasks[action.id],
|
||||||
label: action.label,
|
label: action.label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateParam extends Action {
|
export interface UpdateParam extends Action {
|
||||||
name: string
|
name: string
|
||||||
value: any
|
value: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_PARAM = "UPDATE_PARAM";
|
export const UPDATE_PARAM = "UPDATE_PARAM";
|
||||||
|
|
||||||
export function updateParam(name: string, value: any): UpdateParam {
|
export function updateParam(name: string, value: any): UpdateParam {
|
||||||
return { type: UPDATE_PARAM, name, value };
|
return { type: UPDATE_PARAM, name, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
|
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
params: {
|
||||||
...project.params,
|
...project.params,
|
||||||
[action.name]: action.value,
|
[action.name]: action.value,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskCategoryLabel extends Action {
|
export interface UpdateTaskCategoryLabel extends Action {
|
||||||
categoryId: TaskCategoryID
|
categoryId: TaskCategoryID
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_CATEGORY_LABEL = "UPDATE_TASK_CATEGORY_LABEL";
|
export const UPDATE_TASK_CATEGORY_LABEL = "UPDATE_TASK_CATEGORY_LABEL";
|
||||||
|
|
||||||
export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: string): UpdateTaskCategoryLabel {
|
export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: string): UpdateTaskCategoryLabel {
|
||||||
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
|
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
|
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
params: {
|
||||||
...project.params,
|
...project.params,
|
||||||
taskCategories: {
|
taskCategories: {
|
||||||
...project.params.taskCategories,
|
...project.params.taskCategories,
|
||||||
[action.categoryId]: {
|
[action.categoryId]: {
|
||||||
...project.params.taskCategories[action.categoryId],
|
...project.params.taskCategories[action.categoryId],
|
||||||
label: action.label
|
label: action.label
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskCategoryCost extends Action {
|
export interface UpdateTaskCategoryCost extends Action {
|
||||||
categoryId: TaskCategoryID
|
categoryId: TaskCategoryID
|
||||||
costPerTimeUnit: number
|
costPerTimeUnit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UPDATE_TASK_CATEGORY_COST = "UPDATE_TASK_CATEGORY_COST";
|
export const UPDATE_TASK_CATEGORY_COST = "UPDATE_TASK_CATEGORY_COST";
|
||||||
|
|
||||||
export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUnit: number): UpdateTaskCategoryCost {
|
export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUnit: number): UpdateTaskCategoryCost {
|
||||||
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
|
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
|
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
params: {
|
||||||
...project.params,
|
...project.params,
|
||||||
taskCategories: {
|
taskCategories: {
|
||||||
...project.params.taskCategories,
|
...project.params.taskCategories,
|
||||||
[action.categoryId]: {
|
[action.categoryId]: {
|
||||||
...project.params.taskCategories[action.categoryId],
|
...project.params.taskCategories[action.categoryId],
|
||||||
costPerTimeUnit: action.costPerTimeUnit
|
costPerTimeUnit: action.costPerTimeUnit
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
|
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
|
||||||
|
|
||||||
export interface AddTaskCategory extends Action {
|
export interface AddTaskCategory extends Action {
|
||||||
taskCategory: TaskCategory
|
taskCategory: TaskCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
|
export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
|
||||||
return { type: ADD_TASK_CATEGORY, taskCategory };
|
return { type: ADD_TASK_CATEGORY, taskCategory };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
|
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
|
||||||
const taskCategory = { ...action.taskCategory };
|
const taskCategory = { ...action.taskCategory };
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
params: {
|
||||||
...project.params,
|
...project.params,
|
||||||
taskCategories: {
|
taskCategories: {
|
||||||
...project.params.taskCategories,
|
...project.params.taskCategories,
|
||||||
[taskCategory.id]: taskCategory,
|
[taskCategory.id]: taskCategory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveTaskCategory extends Action {
|
export interface RemoveTaskCategory extends Action {
|
||||||
taskCategoryId: TaskCategoryID
|
taskCategoryId: TaskCategoryID
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REMOVE_TASK_CATEGORY = "REMOVE_TASK_CATEGORY";
|
export const REMOVE_TASK_CATEGORY = "REMOVE_TASK_CATEGORY";
|
||||||
|
|
||||||
export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCategory {
|
export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCategory {
|
||||||
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
|
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
|
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
|
||||||
const taskCategories = { ...project.params.taskCategories };
|
const taskCategories = { ...project.params.taskCategories };
|
||||||
delete taskCategories[action.taskCategoryId];
|
delete taskCategories[action.taskCategoryId];
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
params: {
|
params: {
|
||||||
...project.params,
|
...project.params,
|
||||||
taskCategories
|
taskCategories
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchProject extends Action {
|
||||||
|
from: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATCH_PROJECT = "PATCH_PROJECT";
|
||||||
|
|
||||||
|
export function patchProject(from: Project): PatchProject {
|
||||||
|
return { type: PATCH_PROJECT, from };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePatchProject(project: Project, action: PatchProject): Project {
|
||||||
|
const p = diff(project, action.from);
|
||||||
|
console.log('patch to apply', p);
|
||||||
|
if (!p) return project;
|
||||||
|
return applyPatch(project, p) as Project;
|
||||||
}
|
}
|
|
@ -1,57 +1,108 @@
|
||||||
import { Project } from "../models/project";
|
import { Project } from "../models/project";
|
||||||
import { usePrevious } from "./use-previous";
|
import { usePrevious } from "./use-previous";
|
||||||
import * as jsonpatch from 'fast-json-patch';
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import { Operation } from "fast-json-patch";
|
import { generate as diff } from "json-merge-patch";
|
||||||
|
import useDebounce from "./use-debounce";
|
||||||
|
|
||||||
export interface ServerSyncOptions {
|
export interface ServerSyncOptions {
|
||||||
baseUrl: string
|
projectURL: string
|
||||||
|
refreshInterval: number
|
||||||
|
syncDelay: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultOptions = {
|
export const defaultOptions = {
|
||||||
baseUrl: `ws://${window.location.host}/ws`,
|
refreshInterval: 10000,
|
||||||
|
projectURL: `//${window.location.host}/api/v1/projects`,
|
||||||
|
syncDelay: 5000,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServerSync(project: Project, options: ServerSyncOptions = defaultOptions) {
|
export function useServerSync(project: Project, applyServerUpdate: (project: Project) => void, options: ServerSyncOptions = defaultOptions) {
|
||||||
options = Object.assign({}, defaultOptions, options);
|
options = Object.assign({}, defaultOptions, options);
|
||||||
|
|
||||||
const [ conn, setConn ] = useState<WebSocket>(() => {
|
const [ version, setVersion ] = useState(0);
|
||||||
const conn = new WebSocket(`${options.baseUrl}/${project.id}`);
|
|
||||||
|
|
||||||
conn.onerror = (evt: Event) => {
|
const handleAPIResponse = (res: Response) => {
|
||||||
console.error(evt);
|
// If the project does not yet exist, create it
|
||||||
};
|
if (res.status === 404) {
|
||||||
|
return createProject(project, options)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
setVersion(result.version);
|
||||||
|
})
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
conn.onopen = (evt: Event) => {
|
// In case of conflict, notify of new server version
|
||||||
console.log('ws connection opened');
|
if (res.status === 409) {
|
||||||
};
|
return res.json().then((result: any) => {
|
||||||
|
applyServerUpdate(result.project);
|
||||||
|
setVersion(result.version);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return conn;
|
// If the server version is not modified, do nothing
|
||||||
|
if (res.status === 304) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json().then((result: any) => {
|
||||||
|
applyServerUpdate(result.project);
|
||||||
|
setVersion(result.version);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force refresh periodically
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
refreshProject(project, version, options).then(handleAPIResponse);
|
||||||
|
}, options.refreshInterval);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [version]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId =window.setTimeout(() => {
|
||||||
|
console.log('executing debounced patch');
|
||||||
|
|
||||||
|
let previousProject: Project|any = usePrevious(project);
|
||||||
|
if (!previousProject) previousProject = {};
|
||||||
|
|
||||||
|
// Trigger patch if project has changed
|
||||||
|
|
||||||
|
const patch = diff(previousProject, project);
|
||||||
|
|
||||||
|
console.log('generated patch', patch);
|
||||||
|
|
||||||
|
if (!patch) return;
|
||||||
|
|
||||||
|
patchProject(project, patch, version, options).then(handleAPIResponse);
|
||||||
|
}, options.syncDelay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ ops, setOps ] = useState<Operation[][]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (!conn) return;
|
|
||||||
console.log('closing ws');
|
|
||||||
conn.close();
|
|
||||||
conn.onerror = null;
|
|
||||||
conn.onopen = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
conn.send(JSON.stringify(ops));
|
|
||||||
setOps([]);
|
|
||||||
}, [ops.length > 0]);
|
|
||||||
|
|
||||||
let previousProject: Project|any = usePrevious(project);
|
|
||||||
if (!previousProject) previousProject = {};
|
|
||||||
|
|
||||||
const newOps = jsonpatch.compare(previousProject, project);
|
|
||||||
|
|
||||||
if (ops.length === 0) return;
|
|
||||||
|
|
||||||
setOps(ops => [...ops, newOps]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshProject(project: Project, version: number, options: ServerSyncOptions): Promise<Response> {
|
||||||
|
return fetch(`${options.projectURL}/${project.id}?version=${version}`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(project: Project, options: ServerSyncOptions): Promise<Response> {
|
||||||
|
return fetch(`${options.projectURL}/${project.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ project })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchProject(project: Project, patch: Object, version: number, options: ServerSyncOptions): Promise<any> {
|
||||||
|
return fetch(`${options.projectURL}/${project.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ version, patch })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -2,20 +2,20 @@ import { TaskCategory, TaskCategoryID } from "./task";
|
||||||
import { Project } from "./project";
|
import { Project } from "./project";
|
||||||
|
|
||||||
export interface TaskCategoriesIndex {
|
export interface TaskCategoriesIndex {
|
||||||
[id: string]: TaskCategory
|
[id: string]: TaskCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeUnit {
|
export interface TimeUnit {
|
||||||
label: string
|
label: string
|
||||||
acronym: string
|
acronym: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Params {
|
export interface Params {
|
||||||
taskCategories: TaskCategoriesIndex
|
taskCategories: TaskCategoriesIndex
|
||||||
timeUnit: TimeUnit
|
timeUnit: TimeUnit
|
||||||
currency: string
|
currency: string
|
||||||
roundUpEstimations: boolean
|
roundUpEstimations: boolean
|
||||||
hideFinancialPreviewOnPrint: boolean
|
hideFinancialPreviewOnPrint: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
|
|
|
@ -5,25 +5,25 @@ import { uuidV4 } from "../util/uuid";
|
||||||
export type ProjectID = string;
|
export type ProjectID = string;
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: ProjectID
|
id: ProjectID
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
tasks: Tasks
|
tasks: Tasks
|
||||||
params: Params
|
params: Params
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tasks {
|
export interface Tasks {
|
||||||
[id: string]: Task
|
[id: string]: Task
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newProject(id?: string): Project {
|
export function newProject(id?: string): Project {
|
||||||
return {
|
return {
|
||||||
id: id ? id : uuidV4(),
|
id: id ? id : uuidV4(),
|
||||||
label: "",
|
label: "",
|
||||||
description: "",
|
description: "",
|
||||||
tasks: {},
|
tasks: {},
|
||||||
params: {
|
params: {
|
||||||
...defaults
|
...defaults
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -4,24 +4,24 @@ import { defaults } from "./params";
|
||||||
export type TaskID = string
|
export type TaskID = string
|
||||||
|
|
||||||
export enum EstimationConfidence {
|
export enum EstimationConfidence {
|
||||||
Optimistic = "optimistic",
|
Optimistic = "optimistic",
|
||||||
Likely = "likely",
|
Likely = "likely",
|
||||||
Pessimistic = "pessimistic"
|
Pessimistic = "pessimistic"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
label: string
|
label: string
|
||||||
category: TaskCategoryID
|
category: TaskCategoryID
|
||||||
estimations: { [confidence in EstimationConfidence]: number }
|
estimations: { [confidence in EstimationConfidence]: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskCategoryID = string
|
export type TaskCategoryID = string
|
||||||
|
|
||||||
export interface TaskCategory {
|
export interface TaskCategory {
|
||||||
id: TaskCategoryID
|
id: TaskCategoryID
|
||||||
label: string
|
label: string
|
||||||
costPerTimeUnit: number
|
costPerTimeUnit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newTask(label: string, category: TaskCategoryID): Task {
|
export function newTask(label: string, category: TaskCategoryID): Task {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { FunctionalComponent, h } from "preact";
|
import { FunctionalComponent, h } from "preact";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import style from "./style.module.css";
|
import style from "./style.module.css";
|
||||||
import { newProject } from "../../models/project";
|
import { newProject, Project } from "../../models/project";
|
||||||
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
|
import { useProjectReducer, updateProjectLabel, patchProject } from "../../hooks/use-project-reducer";
|
||||||
import { getProjectStorageKey } from "../../util/storage";
|
import { getProjectStorageKey } from "../../util/storage";
|
||||||
import { useLocalStorage } from "../../hooks/use-local-storage";
|
import { useLocalStorage } from "../../hooks/use-local-storage";
|
||||||
import EditableText from "../../components/editable-text";
|
import EditableText from "../../components/editable-text";
|
||||||
|
@ -20,7 +20,10 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
||||||
const projectStorageKey = getProjectStorageKey(projectId);
|
const projectStorageKey = getProjectStorageKey(projectId);
|
||||||
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
|
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
|
||||||
const [ project, dispatch ] = useProjectReducer(storedProject);
|
const [ project, dispatch ] = useProjectReducer(storedProject);
|
||||||
useServerSync(project)
|
|
||||||
|
useServerSync(project, (project: Project) => {
|
||||||
|
dispatch(patchProject(project));
|
||||||
|
});
|
||||||
|
|
||||||
const onProjectLabelChange = (projectLabel: string) => {
|
const onProjectLabelChange = (projectLabel: string) => {
|
||||||
dispatch(updateProjectLabel(projectLabel));
|
dispatch(updateProjectLabel(projectLabel));
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
|
||||||
|
// React/Redux compatible implementation of RFC 7396
|
||||||
|
// See https://tools.ietf.org/html/rfc7396
|
||||||
|
//
|
||||||
|
// Pseudo algorithm:
|
||||||
|
//
|
||||||
|
// define MergePatch(Target, Patch):
|
||||||
|
// if Patch is an Object:
|
||||||
|
// if Target is not an Object:
|
||||||
|
// Target = {} # Ignore the contents and set it to an empty Object
|
||||||
|
// for each Name/Value pair in Patch:
|
||||||
|
// if Value is null:
|
||||||
|
// if Name exists in Target:
|
||||||
|
// remove the Name/Value pair from Target
|
||||||
|
// else:
|
||||||
|
// Target[Name] = MergePatch(Target[Name], Value)
|
||||||
|
// return Target
|
||||||
|
// else:
|
||||||
|
// return Patch
|
||||||
|
|
||||||
|
export function applyPatch(target: any, patch: any): Object {
|
||||||
|
if (!isObject(patch)) {
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(target)) {
|
||||||
|
target = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(patch).forEach((key: any) => {
|
||||||
|
const value = patch[key];
|
||||||
|
target = { ...target };
|
||||||
|
if (value === null) {
|
||||||
|
delete target[key];
|
||||||
|
} else {
|
||||||
|
target[key] = applyPatch(target[key], value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: any): boolean {
|
||||||
|
return value === Object(value);
|
||||||
|
}
|
|
@ -24,9 +24,8 @@ module.exports = {
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
hot: env.NODE_ENV === 'development',
|
hot: env.NODE_ENV === 'development',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/ws': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8081',
|
target: 'http://127.0.0.1:8081',
|
||||||
ws: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,5 +15,5 @@ modd.conf {
|
||||||
|
|
||||||
server/**/*.go
|
server/**/*.go
|
||||||
server/**/*_test.go {
|
server/**/*_test.go {
|
||||||
prep: cd server && go clean -testcache && go test -v ./...
|
prep: cd server && go clean -testcache && go test -race -v ./...
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.cadoles.com/wpetit/guesstimate/internal/config"
|
"forge.cadoles.com/wpetit/guesstimate/internal/config"
|
||||||
|
"forge.cadoles.com/wpetit/guesstimate/internal/model"
|
||||||
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
|
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
|
||||||
"gitlab.com/wpetit/goweb/service"
|
"gitlab.com/wpetit/goweb/service"
|
||||||
"gitlab.com/wpetit/goweb/service/build"
|
"gitlab.com/wpetit/goweb/service/build"
|
||||||
|
@ -18,6 +19,9 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) {
|
||||||
|
|
||||||
ctn.Provide(storm.ServiceName, storm.ServiceProvider(
|
ctn.Provide(storm.ServiceName, storm.ServiceProvider(
|
||||||
storm.WithPath(conf.Data.Path),
|
storm.WithPath(conf.Data.Path),
|
||||||
|
storm.WithObjects(&model.ProjectEntry{}),
|
||||||
|
storm.WithInit(true),
|
||||||
|
storm.WithReIndex(true),
|
||||||
))
|
))
|
||||||
|
|
||||||
return ctn, nil
|
return ctn, nil
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type ProjectID string
|
||||||
|
|
||||||
|
type ProjectEntry struct {
|
||||||
|
ID ProjectID `storm:"id"`
|
||||||
|
Version uint64 `storm:"index"`
|
||||||
|
Project *Project
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID ProjectID `json:"id" storm:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tasks map[TaskID]Task `json:"tasks"`
|
||||||
|
Params Params `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params struct {
|
||||||
|
TaskCategories map[TaskCategoryID]TaskCategory `json:"taskCategories"`
|
||||||
|
TimeUnit TimeUnit `json:"timeUnit"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
RoundUpEstimations bool `json:"roundUpEstimations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeUnit struct {
|
||||||
|
Acronym string `json:"acronym"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type TaskID string
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID TaskID `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Estimations TaskEstimations `json:"estimations"`
|
||||||
|
CategoryID TaskCategoryID `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskEstimations struct {
|
||||||
|
Optimistic float64 `json:"optimistic"`
|
||||||
|
Likely float64 `json:"likely"`
|
||||||
|
Pessimistic float64 `json:"pessimistic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskCategoryID string
|
||||||
|
|
||||||
|
type TaskCategory struct {
|
||||||
|
ID TaskCategoryID `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CostPerTimeUnit float64 `json:"costPerTimeUnit"`
|
||||||
|
}
|
|
@ -6,7 +6,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Mount(r *chi.Mux, config *config.Config) error {
|
func Mount(r *chi.Mux, config *config.Config) error {
|
||||||
r.Get("/ws/{projectId}", handleProjectWebsocket)
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/projects/{projectID}", handleGetProject)
|
||||||
|
r.Post("/projects/{projectID}", handleCreateProject)
|
||||||
|
r.Patch("/projects/{projectID}", handlePatchProject)
|
||||||
|
r.Delete("/projects/{projectID}", handleDeleteProject)
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,109 +1,248 @@
|
||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
jsonpatch "gopkg.in/evanphx/json-patch.v4"
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
|
"forge.cadoles.com/wpetit/guesstimate/internal/model"
|
||||||
|
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
|
||||||
|
"github.com/go-chi/chi"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gitlab.com/wpetit/goweb/logger"
|
"gitlab.com/wpetit/goweb/middleware/container"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{} // nolint: gochecknoglobals
|
type projectResponse struct {
|
||||||
|
Version uint64 `json:"version"`
|
||||||
|
Project *model.Project `json:"project"`
|
||||||
|
}
|
||||||
|
|
||||||
func handleProjectWebsocket(w http.ResponseWriter, r *http.Request) {
|
func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Println("websocket request")
|
ctn := container.Must(r.Context())
|
||||||
|
db := storm.Must(ctn)
|
||||||
|
|
||||||
c, err := upgrader.Upgrade(w, r, nil)
|
projectID := getProjectID(r)
|
||||||
if err != nil {
|
|
||||||
panic(errors.Wrap(err, "could not upgrade connection"))
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
// ctn := container.Must(r.Context())
|
var (
|
||||||
|
version uint64
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
// ctx := r.Context()
|
rawVersion := r.URL.Query().Get("version")
|
||||||
|
if rawVersion != "" {
|
||||||
// Loop over incoming websocket messages
|
version, err = strconv.ParseUint(rawVersion, 10, 64)
|
||||||
for {
|
|
||||||
_, data, err := c.ReadMessage()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cause := errors.Cause(err)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
if websocket.IsCloseError(cause, 1001, 1005) { // Ignore "going away" and "no status" close errors
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Error(
|
return
|
||||||
r.Context(),
|
}
|
||||||
"could not read message",
|
}
|
||||||
logger.E(err),
|
|
||||||
)
|
|
||||||
|
|
||||||
break
|
tx, err := db.Begin(false)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not start transaction"))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
|
||||||
|
panic(errors.Wrap(err, "could not rollback transaction"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
entry := &model.ProjectEntry{}
|
||||||
|
if err := tx.One("ID", projectID, entry); err != nil {
|
||||||
|
if err == storm.ErrNotFound {
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
spew.Dump(data)
|
panic(errors.Wrapf(err, "could not find project '%s'", projectID))
|
||||||
|
}
|
||||||
|
|
||||||
// message := &game.Message{}
|
if rawVersion != "" && entry.Version == version {
|
||||||
// if err := json.Unmarshal(data, message); err != nil {
|
http.Error(w, http.StatusText(http.StatusNotModified), http.StatusNotModified)
|
||||||
// logger.Error(
|
|
||||||
// r.Context(),
|
|
||||||
// "could not decode message",
|
|
||||||
// logger.E(err),
|
|
||||||
// )
|
|
||||||
|
|
||||||
// break
|
return
|
||||||
// }
|
}
|
||||||
|
|
||||||
// switch {
|
if err := writeJSON(w, http.StatusOK, &projectResponse{entry.Version, entry.Project}); err != nil {
|
||||||
// case message.Type == game.MessageTypeInit:
|
panic(errors.Wrap(err, "could not write json"))
|
||||||
// payload := &game.InitPayload{}
|
|
||||||
|
|
||||||
// if err := json.Unmarshal(message.Payload, payload); err != nil {
|
|
||||||
// logger.Error(
|
|
||||||
// r.Context(),
|
|
||||||
// "could not decode payload",
|
|
||||||
// logger.E(err),
|
|
||||||
// )
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
|
|
||||||
// setGameID(payload.GameID)
|
|
||||||
|
|
||||||
// evt := game.NewEventPlayerConnected(payload.GameID, user.ID)
|
|
||||||
// bus.Publish(game.EventNamespace, evt)
|
|
||||||
|
|
||||||
// case message.Type == game.MessageTypeGameEvent:
|
|
||||||
// gameID, ok := getGameID()
|
|
||||||
// if !ok {
|
|
||||||
// logger.Error(
|
|
||||||
// r.Context(),
|
|
||||||
// "game id not received yet",
|
|
||||||
// )
|
|
||||||
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
|
|
||||||
// payload := &game.EventPayload{}
|
|
||||||
// if err := json.Unmarshal(message.Payload, payload); err != nil {
|
|
||||||
// logger.Error(
|
|
||||||
// r.Context(),
|
|
||||||
// "could not decode payload",
|
|
||||||
// logger.E(err),
|
|
||||||
// )
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
|
|
||||||
// evt := game.NewEventPlayerMessage(gameID, user.ID, payload.Data)
|
|
||||||
// bus.Publish(game.EventNamespace, evt)
|
|
||||||
|
|
||||||
// default:
|
|
||||||
// logger.Error(
|
|
||||||
// r.Context(),
|
|
||||||
// "unsupported message type",
|
|
||||||
// logger.F("messageType", message.Type),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type createRequest struct {
|
||||||
|
Project *model.Project `json:"project"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctn := container.Must(r.Context())
|
||||||
|
db := storm.Must(ctn)
|
||||||
|
|
||||||
|
projectID := getProjectID(r)
|
||||||
|
|
||||||
|
log.Printf("handling create request for project %s", projectID)
|
||||||
|
|
||||||
|
createReq := &createRequest{}
|
||||||
|
if err := parseJSONBody(r, createReq); err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
|
||||||
|
panic(errors.Wrap(err, "could not parse create request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin(true)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not start transaction"))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
|
||||||
|
panic(errors.Wrap(err, "could not rollback transaction"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
entry := &model.ProjectEntry{}
|
||||||
|
|
||||||
|
err = tx.One("ID", projectID, entry)
|
||||||
|
if err == nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != storm.ErrNotFound {
|
||||||
|
panic(errors.Wrapf(err, "could not check project '%s'", projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.ID = projectID
|
||||||
|
entry.Project = createReq.Project
|
||||||
|
entry.Version = 0
|
||||||
|
|
||||||
|
if err := tx.Save(entry); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not save project"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not commit transaction"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeJSON(w, http.StatusCreated, &projectResponse{entry.Version, entry.Project}); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not write json response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type patchRequest struct {
|
||||||
|
Version uint64 `json:"version"`
|
||||||
|
Patch json.RawMessage `json:"patch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctn := container.Must(r.Context())
|
||||||
|
db := storm.Must(ctn)
|
||||||
|
|
||||||
|
projectID := getProjectID(r)
|
||||||
|
|
||||||
|
log.Printf("handling patch request for project %s", projectID)
|
||||||
|
|
||||||
|
patchReq := &patchRequest{}
|
||||||
|
if err := parseJSONBody(r, patchReq); err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
|
||||||
|
panic(errors.Wrap(err, "could not parse patch request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin(true)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not start transaction"))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
|
||||||
|
panic(errors.Wrap(err, "could not rollback transaction"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
entry := &model.ProjectEntry{}
|
||||||
|
if err := tx.One("ID", projectID, entry); err != nil {
|
||||||
|
if err == storm.ErrNotFound {
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(errors.Wrapf(err, "could not find project '%s'", projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Version != patchReq.Version {
|
||||||
|
if err := writeJSON(w, http.StatusConflict, &projectResponse{entry.Version, entry.Project}); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not write json response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectData, err := json.Marshal(entry.Project)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not marshal project"))
|
||||||
|
}
|
||||||
|
|
||||||
|
projectData, err = jsonpatch.MergePatch(projectData, patchReq.Patch)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not merge project patch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
newProject := &model.Project{}
|
||||||
|
if err := json.Unmarshal(projectData, newProject); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not merge project patch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Version++
|
||||||
|
entry.Project = newProject
|
||||||
|
|
||||||
|
if err := tx.Save(entry); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not save project"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not commit transaction"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeJSON(w, http.StatusOK, &projectResponse{entry.Version, entry.Project}); err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not write json response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProjectID(r *http.Request) model.ProjectID {
|
||||||
|
return model.ProjectID(chi.URLParam(r, "projectID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONBody(r *http.Request, payload interface{}) (err error) {
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err = r.Body.Close(); err != nil {
|
||||||
|
err = errors.Wrap(err, "could not close request body")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := decoder.Decode(payload); err != nil {
|
||||||
|
return errors.Wrap(err, "could not decode request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return encoder.Encode(data)
|
||||||
|
}
|
||||||
|
|
|
@ -5,5 +5,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = storm.ErrNotFound
|
ErrNotFound = storm.ErrNotFound
|
||||||
|
ErrNotInTransaction = storm.ErrNotInTransaction
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue