Basic storage backend with diff/patch synchronization

This commit is contained in:
wpetit 2020-05-03 18:34:44 +02:00
parent 1ac485abf3
commit a9c24051b0
20 changed files with 734 additions and 398 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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);
}; };

View File

@ -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]
)
}

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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 })
});
};

View File

@ -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 = {

View File

@ -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
}, },
}; };
} }

View File

@ -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 {

View File

@ -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));

45
client/src/util/patch.ts Normal file
View File

@ -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);
}

View File

@ -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
} }
} }
}, },

View File

@ -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 ./...
} }

View File

@ -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

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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
} }

View File

@ -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)
}

View File

@ -5,5 +5,6 @@ import (
) )
var ( var (
ErrNotFound = storm.ErrNotFound ErrNotFound = storm.ErrNotFound
ErrNotInTransaction = storm.ErrNotInTransaction
) )