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==",
|
||||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
|
@ -1729,7 +1734,6 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
|
||||
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-date-object": "^1.0.1",
|
||||
|
@ -1753,7 +1757,6 @@
|
|||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
|
||||
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"object-keys": "^1.0.12"
|
||||
}
|
||||
|
@ -2041,7 +2044,6 @@
|
|||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
|
@ -2060,7 +2062,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
|
||||
"integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-callable": "^1.1.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
@ -3356,8 +3352,7 @@
|
|||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"get-caller-file": {
|
||||
"version": "1.0.3",
|
||||
|
@ -3487,7 +3482,6 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
|
@ -3510,8 +3504,7 @@
|
|||
"has-symbols": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
|
||||
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg=="
|
||||
},
|
||||
"has-value": {
|
||||
"version": "1.0.0",
|
||||
|
@ -4018,8 +4011,7 @@
|
|||
"is-arguments": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
|
||||
"integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA=="
|
||||
},
|
||||
"is-binary-path": {
|
||||
"version": "1.0.1",
|
||||
|
@ -4039,8 +4031,7 @@
|
|||
"is-callable": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||
},
|
||||
"is-data-descriptor": {
|
||||
"version": "0.1.4",
|
||||
|
@ -4065,8 +4056,7 @@
|
|||
"is-date-object": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
|
||||
"integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==",
|
||||
"dev": true
|
||||
"integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g=="
|
||||
},
|
||||
"is-descriptor": {
|
||||
"version": "0.1.6",
|
||||
|
@ -4163,7 +4153,6 @@
|
|||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
|
@ -4178,7 +4167,6 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
|
||||
"integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-symbols": "^1.0.1"
|
||||
}
|
||||
|
@ -4219,6 +4207,14 @@
|
|||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
|
||||
|
@ -4777,14 +4773,12 @@
|
|||
"object-inspect": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
|
||||
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw=="
|
||||
},
|
||||
"object-is": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
|
||||
"integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5"
|
||||
|
@ -4793,8 +4787,7 @@
|
|||
"object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
|
||||
},
|
||||
"object-path": {
|
||||
"version": "0.11.4",
|
||||
|
@ -4815,7 +4808,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
|
||||
"integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.2",
|
||||
"function-bind": "^1.1.1",
|
||||
|
@ -5614,7 +5606,6 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
|
||||
"integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.0-next.1"
|
||||
|
@ -6466,7 +6457,6 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
|
||||
"integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5"
|
||||
|
@ -6476,7 +6466,6 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
|
@ -6487,7 +6476,6 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
|
@ -6498,7 +6486,6 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
|
||||
"integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5"
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
"main": "src/index.js",
|
||||
"dependencies": {
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/json-merge-patch": "0.0.4",
|
||||
"bs58": "^4.0.1",
|
||||
"bulma": "^0.8.2",
|
||||
"bulma-switch": "^2.0.0",
|
||||
"fast-json-patch": "^3.0.0-1",
|
||||
"json-merge-patch": "^0.2.3",
|
||||
"preact": "^10.4.1",
|
||||
"preact-markup": "^1.6.0",
|
||||
"preact-render-to-string": "^5.1.6",
|
||||
|
|
|
@ -15,9 +15,14 @@ const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value,
|
|||
const [ editMode, setEditMode ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalValue === value) return;
|
||||
if (onChange) onChange(internalValue);
|
||||
}, [internalValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value])
|
||||
|
||||
const onEditIconClick = () => {
|
||||
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]
|
||||
)
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import { Project } from "../models/project";
|
||||
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
|
||||
import { useReducer } from "preact/hooks";
|
||||
import { generate as diff } from "json-merge-patch";
|
||||
import { applyPatch } from "../util/patch";
|
||||
|
||||
|
||||
export interface Action {
|
||||
type: string
|
||||
|
@ -16,7 +19,8 @@ export type ProjectReducerActions =
|
|||
UpdateTaskCategoryLabel |
|
||||
UpdateTaskCategoryCost |
|
||||
AddTaskCategory |
|
||||
RemoveTaskCategory
|
||||
RemoveTaskCategory |
|
||||
PatchProject
|
||||
|
||||
export function useProjectReducer(project: Project) {
|
||||
return useReducer(projectReducer, project);
|
||||
|
@ -54,6 +58,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
|
|||
|
||||
case UPDATE_TASK_CATEGORY_COST:
|
||||
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
|
||||
|
||||
case PATCH_PROJECT:
|
||||
return handlePatchProject(project, action as PatchProject);
|
||||
}
|
||||
|
||||
return project;
|
||||
|
@ -299,3 +306,20 @@ export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCat
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 { usePrevious } from "./use-previous";
|
||||
import * as jsonpatch from 'fast-json-patch';
|
||||
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 {
|
||||
baseUrl: string
|
||||
projectURL: string
|
||||
refreshInterval: number
|
||||
syncDelay: number
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const [ conn, setConn ] = useState<WebSocket>(() => {
|
||||
const conn = new WebSocket(`${options.baseUrl}/${project.id}`);
|
||||
const [ version, setVersion ] = useState(0);
|
||||
|
||||
conn.onerror = (evt: Event) => {
|
||||
console.error(evt);
|
||||
};
|
||||
const handleAPIResponse = (res: Response) => {
|
||||
// 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) => {
|
||||
console.log('ws connection opened');
|
||||
};
|
||||
|
||||
return conn;
|
||||
// In case of conflict, notify of new server version
|
||||
if (res.status === 409) {
|
||||
return res.json().then((result: any) => {
|
||||
applyServerUpdate(result.project);
|
||||
setVersion(result.version);
|
||||
});
|
||||
}
|
||||
|
||||
const [ ops, setOps ] = useState<Operation[][]>([]);
|
||||
// If the server version is not modified, do nothing
|
||||
if (res.status === 304) {
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!conn) return;
|
||||
console.log('closing ws');
|
||||
conn.close();
|
||||
conn.onerror = null;
|
||||
conn.onopen = null;
|
||||
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(() => {
|
||||
conn.send(JSON.stringify(ops));
|
||||
setOps([]);
|
||||
}, [ops.length > 0]);
|
||||
const timeoutId =window.setTimeout(() => {
|
||||
console.log('executing debounced patch');
|
||||
|
||||
let previousProject: Project|any = usePrevious(project);
|
||||
if (!previousProject) previousProject = {};
|
||||
|
||||
const newOps = jsonpatch.compare(previousProject, project);
|
||||
// Trigger patch if project has changed
|
||||
|
||||
if (ops.length === 0) return;
|
||||
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);
|
||||
});
|
||||
|
||||
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 })
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { FunctionalComponent, h } from "preact";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import style from "./style.module.css";
|
||||
import { newProject } from "../../models/project";
|
||||
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer";
|
||||
import { newProject, Project } from "../../models/project";
|
||||
import { useProjectReducer, updateProjectLabel, patchProject } from "../../hooks/use-project-reducer";
|
||||
import { getProjectStorageKey } from "../../util/storage";
|
||||
import { useLocalStorage } from "../../hooks/use-local-storage";
|
||||
import EditableText from "../../components/editable-text";
|
||||
|
@ -20,7 +20,10 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
|||
const projectStorageKey = getProjectStorageKey(projectId);
|
||||
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
|
||||
const [ project, dispatch ] = useProjectReducer(storedProject);
|
||||
useServerSync(project)
|
||||
|
||||
useServerSync(project, (project: Project) => {
|
||||
dispatch(patchProject(project));
|
||||
});
|
||||
|
||||
const onProjectLabelChange = (projectLabel: string) => {
|
||||
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,
|
||||
hot: env.NODE_ENV === 'development',
|
||||
proxy: {
|
||||
'/ws': {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8081',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -15,5 +15,5 @@ modd.conf {
|
|||
|
||||
server/**/*.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 (
|
||||
"forge.cadoles.com/wpetit/guesstimate/internal/config"
|
||||
"forge.cadoles.com/wpetit/guesstimate/internal/model"
|
||||
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
"gitlab.com/wpetit/goweb/service/build"
|
||||
|
@ -18,6 +19,9 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) {
|
|||
|
||||
ctn.Provide(storm.ServiceName, storm.ServiceProvider(
|
||||
storm.WithPath(conf.Data.Path),
|
||||
storm.WithObjects(&model.ProjectEntry{}),
|
||||
storm.WithInit(true),
|
||||
storm.WithReIndex(true),
|
||||
))
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,109 +1,248 @@
|
|||
package route
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/gorilla/websocket"
|
||||
jsonpatch "gopkg.in/evanphx/json-patch.v4"
|
||||
|
||||
"forge.cadoles.com/wpetit/guesstimate/internal/model"
|
||||
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{} // nolint: gochecknoglobals
|
||||
|
||||
func handleProjectWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("websocket request")
|
||||
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not upgrade connection"))
|
||||
type projectResponse struct {
|
||||
Version uint64 `json:"version"`
|
||||
Project *model.Project `json:"project"`
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// ctn := container.Must(r.Context())
|
||||
func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
ctn := container.Must(r.Context())
|
||||
db := storm.Must(ctn)
|
||||
|
||||
// ctx := r.Context()
|
||||
projectID := getProjectID(r)
|
||||
|
||||
// Loop over incoming websocket messages
|
||||
for {
|
||||
_, data, err := c.ReadMessage()
|
||||
var (
|
||||
version uint64
|
||||
err error
|
||||
)
|
||||
|
||||
rawVersion := r.URL.Query().Get("version")
|
||||
if rawVersion != "" {
|
||||
version, err = strconv.ParseUint(rawVersion, 10, 64)
|
||||
if err != nil {
|
||||
cause := errors.Cause(err)
|
||||
if websocket.IsCloseError(cause, 1001, 1005) { // Ignore "going away" and "no status" close errors
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
logger.Error(
|
||||
r.Context(),
|
||||
"could not read message",
|
||||
logger.E(err),
|
||||
)
|
||||
|
||||
break
|
||||
panic(errors.Wrapf(err, "could not find project '%s'", projectID))
|
||||
}
|
||||
|
||||
spew.Dump(data)
|
||||
if rawVersion != "" && entry.Version == version {
|
||||
http.Error(w, http.StatusText(http.StatusNotModified), http.StatusNotModified)
|
||||
|
||||
// message := &game.Message{}
|
||||
// if err := json.Unmarshal(data, message); err != nil {
|
||||
// logger.Error(
|
||||
// r.Context(),
|
||||
// "could not decode message",
|
||||
// logger.E(err),
|
||||
// )
|
||||
return
|
||||
}
|
||||
|
||||
// break
|
||||
// }
|
||||
|
||||
// switch {
|
||||
// case message.Type == game.MessageTypeInit:
|
||||
// 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),
|
||||
// )
|
||||
// }
|
||||
if err := writeJSON(w, http.StatusOK, &projectResponse{entry.Version, entry.Project}); err != nil {
|
||||
panic(errors.Wrap(err, "could not write json"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -6,4 +6,5 @@ import (
|
|||
|
||||
var (
|
||||
ErrNotFound = storm.ErrNotFound
|
||||
ErrNotInTransaction = storm.ErrNotInTransaction
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue