Implement `syncState` (WIP)

This commit is contained in:
josdejong 2020-06-02 17:58:13 +02:00
parent 41c633e124
commit 596d868cc5
9 changed files with 230 additions and 94 deletions

View File

@ -12,12 +12,13 @@
import { createHistory } from './history.js' import { createHistory } from './history.js'
import Node from './JSONNode.svelte' import Node from './JSONNode.svelte'
import { existsIn, getIn, setIn } from './utils/immutabilityHelpers.js' import { existsIn, getIn, setIn } from './utils/immutabilityHelpers.js'
import { compileJSONPointer } from './utils/jsonPointer.js' import { compileJSONPointer, parseJSONPointer } from './utils/jsonPointer.js'
import { keyComboFromEvent } from './utils/keyBindings.js' import { keyComboFromEvent } from './utils/keyBindings.js'
import { flattenSearch, search } from './utils/search.js' import { flattenSearch, search } from './utils/search.js'
import { immutableJSONPatch } from './utils/immutableJSONPatch' import { immutableJSONPatch } from './utils/immutableJSONPatch'
import { isEqual, isNumber } from 'lodash-es' import { isEqual, isNumber, initial, last } from 'lodash-es'
import jump from './assets/jump.js/src/jump.js' import jump from './assets/jump.js/src/jump.js'
import { syncState } from './utils/syncState.js'
let divContents let divContents
@ -28,15 +29,20 @@
console.timeEnd('render') console.timeEnd('render')
}) })
export let json = {} export let json = {} // TODO: rename 'json' to 'document'
export let onChangeJson = () => { export let onChangeJson = () => {}
function expand (path) {
return path.length < 1
} }
const INITIAL_STATE = { let state
[STATE_EXPANDED]: true
}
let state = INITIAL_STATE $: {
console.time('syncState')
state = syncState(json, state, [], expand)
console.timeEnd('syncState')
}
let showSearch = false let showSearch = false
let searchText = '' let searchText = ''
@ -54,39 +60,85 @@
export function set(newJson) { export function set(newJson) {
json = newJson json = newJson
state = INITIAL_STATE state = undefined
history.clear() history.clear()
} }
function applyPatch (operations) {
const patchResult = immutableJSONPatch(json, operations)
json = patchResult.json
state = immutableJSONPatch(state, operations).json
return patchResult
}
export function patch(operations) { export function patch(operations) {
console.log('patch', operations) const prevState = state
const patchResult = applyPatch(operations) const documentPatchResult = immutableJSONPatch(json, operations)
const statePatchResult = immutableJSONPatch(state, operations)
// TODO: only apply operations to state for relevant operations: move, copy, delete? Figure out
history.add({ json = documentPatchResult.json
undo: patchResult.revert, state = statePatchResult.json
redo: operations
// if a property is renamed (move operation), rename it in the object's props
// so it maintains its identity and hence its index
operations
.filter(operation => {
return operation.op === 'move' && isEqual(
initial(parseJSONPointer(operation.from)),
initial(parseJSONPointer(operation.path))
)
})
.forEach(operation => {
const pathFrom = parseJSONPointer(operation.from)
const to = parseJSONPointer(operation.path)
const parentPath = initial(pathFrom)
const oldKey = last(pathFrom)
const newKey = last(to)
const props = getIn(state, parentPath.concat(STATE_PROPS))
const index = props.findIndex(item => item.key === oldKey)
if (index !== -1) {
state = setIn(state, parentPath.concat([STATE_PROPS, index, 'key']), newKey)
}
}) })
json = patchResult.json history.add({
undo: documentPatchResult.revert,
redo: operations,
prevState: prevState,
state: state
})
return { return {
json, json,
error: patchResult.error, error: documentPatchResult.error,
undo: patchResult.revert, undo: documentPatchResult.revert,
redo: operations redo: operations
} }
} }
function handleUndo() {
if (history.getState().canUndo) {
const item = history.undo()
if (item) {
json = immutableJSONPatch(json, item.undo).json
state = item.prevState
console.log('undo', { item, json, state })
emitOnChange()
}
}
}
function handleRedo() {
if (history.getState().canRedo) {
const item = history.redo()
if (item) {
json = immutableJSONPatch(json, item.redo).json
state = item.state
console.log('redo', { item, json, state })
emitOnChange()
}
}
}
function doSearch(json, searchText) { function doSearch(json, searchText) {
return search(null, json, searchText) return search(null, json, searchText)
} }
@ -177,32 +229,13 @@
showSearch = !showSearch showSearch = !showSearch
} }
function handleUndo() {
if (history.getState().canUndo) {
const item = history.undo()
if (item) {
applyPatch(item.undo)
emitOnChange()
}
}
}
function handleRedo() {
if (history.getState().canRedo) {
const item = history.redo()
if (item) {
applyPatch(item.redo)
emitOnChange()
}
}
}
/** /**
* Toggle expanded state of a node * Toggle expanded state of a node
* @param {Path} path * @param {Path} path
* @param {boolean} expanded * @param {boolean} expanded
*/ */
function handleExpand (path, expanded) { function handleExpand (path, expanded) {
console.log('handleExpand', path, expanded)
state = setIn(state, path.concat(STATE_EXPANDED), expanded) state = setIn(state, path.concat(STATE_EXPANDED), expanded)
} }
@ -215,15 +248,6 @@
state = setIn(state, path.concat(STATE_LIMIT), limit) state = setIn(state, path.concat(STATE_LIMIT), limit)
} }
/**
* Update object properties
* @param {Path} path
* @param {Object} props
*/
function handleUpdateProps (path, props) {
state = setIn(state, path.concat(STATE_PROPS), props)
}
/** /**
* Expand all nodes on given path * Expand all nodes on given path
* @param {Path} path * @param {Path} path
@ -341,7 +365,6 @@
onPatch={handlePatch} onPatch={handlePatch}
onExpand={handleExpand} onExpand={handleExpand}
onLimit={handleLimit} onLimit={handleLimit}
onUpdateProps={handleUpdateProps}
/> />
<div class='bottom'></div> <div class='bottom'></div>
</div> </div>

View File

@ -1,6 +1,5 @@
<script> <script>
import { isObject } from 'lodash-es' import { debounce } from 'lodash-es'
import { onMount } from 'svelte'
import { import {
DEBOUNCE_DELAY, DEFAULT_LIMIT, DEBOUNCE_DELAY, DEFAULT_LIMIT,
STATE_EXPANDED, STATE_LIMIT, STATE_PROPS, STATE_EXPANDED, STATE_LIMIT, STATE_PROPS,
@ -11,7 +10,6 @@
import Icon from 'svelte-awesome' import Icon from 'svelte-awesome'
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons' import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'
import classnames from 'classnames' import classnames from 'classnames'
import debounce from 'lodash/debounce'
import { findUniqueName } from './utils/stringUtils.js' import { findUniqueName } from './utils/stringUtils.js'
import { isUrl, stringConvert, valueType } from './utils/typeUtils' import { isUrl, stringConvert, valueType } from './utils/typeUtils'
import { updateProps } from './utils/updateProps.js' import { updateProps } from './utils/updateProps.js'
@ -26,11 +24,10 @@
export let onChangeKey export let onChangeKey
export let onExpand export let onExpand
export let onLimit export let onLimit
export let onUpdateProps
$: expanded = state && state[STATE_EXPANDED] $: expanded = state && state[STATE_EXPANDED]
$: limit = state && state[STATE_LIMIT] || DEFAULT_LIMIT $: limit = state && state[STATE_LIMIT]
$: props = state && state[STATE_PROPS] || [] $: props = state && state[STATE_PROPS]
const escapeUnicode = false // TODO: pass via options const escapeUnicode = false // TODO: pass via options
@ -39,17 +36,6 @@
$: type = valueType (value) $: type = valueType (value)
let prevValue = undefined
// let props = undefined
$: if (isObject(value) && value !== prevValue) {
prevValue = value
const updatedProps = updateProps(value, props)
onUpdateProps(path, updatedProps)
}
// $: console.log('props', props)
$: limited = type === 'array' && value.length > limit $: limited = type === 'array' && value.length > limit
$: items = type === 'array' $: items = type === 'array'
@ -183,6 +169,7 @@
} }
} }
// TODO: can we do a handleChangeKey in the child again?
function handleChangeKey (newChildKey, oldChildKey) { function handleChangeKey (newChildKey, oldChildKey) {
if (type === 'object') { if (type === 'object') {
// make sure the key is not a duplicate of an other property // make sure the key is not a duplicate of an other property
@ -190,18 +177,18 @@
if (uniqueNewChildKey !== oldChildKey) { if (uniqueNewChildKey !== oldChildKey) {
// we need to make sure that the renamed property will keep the same id // we need to make sure that the renamed property will keep the same id
const index = props.findIndex(item => item.key === oldChildKey) // const index = props.findIndex(item => item.key === oldChildKey)
if (index !== -1) { // if (index !== -1) {
// we use splice here to replace the old key with the new new one // // we use splice here to replace the old key with the new new one
// already without Svelte noticing it (no assignment), so we prevent // // already without Svelte noticing it (no assignment), so we prevent
// a needless render. We keep the same id, so the child HTML will be // // a needless render. We keep the same id, so the child HTML will be
// reused // // reused
// TODO: is there a better way to do this? // // TODO: is there a better way to do this?
props.splice(index, 1, { // props.splice(index, 1, {
id: props[index].id, // id: props[index].id,
key: uniqueNewChildKey // key: uniqueNewChildKey
}) // })
} // }
onPatch([{ onPatch([{
op: 'move', op: 'move',
@ -260,7 +247,6 @@
onPatch={onPatch} onPatch={onPatch}
onExpand={onExpand} onExpand={onExpand}
onLimit={onLimit} onLimit={onLimit}
onUpdateProps={onUpdateProps}
/> />
{/each} {/each}
{#if limited} {#if limited}
@ -298,7 +284,7 @@
<span class="delimiter">&#123;</span> <span class="delimiter">&#123;</span>
{:else} {:else}
<span class="delimiter"> &#123;</span> <span class="delimiter"> &#123;</span>
<button class="tag" on:click={() => onExpand(path, true)}>{props.length} props</button> <button class="tag" on:click={() => onExpand(path, true)}>{Object.keys(value).length} props</button>
<span class="delimiter">}</span> <span class="delimiter">}</span>
{/if} {/if}
</div> </div>
@ -315,7 +301,6 @@
onPatch={onPatch} onPatch={onPatch}
onExpand={onExpand} onExpand={onExpand}
onLimit={onLimit} onLimit={onLimit}
onUpdateProps={onUpdateProps}
/> />
{/each} {/each}
</div> </div>

View File

@ -2,7 +2,7 @@
const MAX_HISTORY_ITEMS = 1000 const MAX_HISTORY_ITEMS = 1000
/** /**
* @typedef {Object} HistoryItem * @typedef {*} HistoryItem
* @property {Object} undo * @property {Object} undo
* @property {Object} redo * @property {Object} redo
*/ */

View File

@ -1,3 +1,4 @@
import { STATE_EXPANDED, STATE_LIMIT } from '../constants.js'
import { isObjectOrArray } from './typeUtils.js' import { isObjectOrArray } from './typeUtils.js'
/** /**
@ -19,7 +20,17 @@ import { isObjectOrArray } from './typeUtils.js'
export function shallowClone (value) { export function shallowClone (value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// copy array items // copy array items
return value.slice() const copy = value.slice()
// FIXME: this is very ugly hack to copy properties attached to an Array!
if (value[STATE_EXPANDED] !== undefined) {
copy[STATE_EXPANDED] = value[STATE_EXPANDED]
}
if (value[STATE_LIMIT] !== undefined) {
copy[STATE_LIMIT] = value[STATE_LIMIT]
}
return copy
} }
else if (typeof value === 'object') { else if (typeof value === 'object') {
// copy object properties // copy object properties

View File

@ -6,8 +6,8 @@ import {
setIn setIn
} from './immutabilityHelpers.js' } from './immutabilityHelpers.js'
import { compileJSONPointer, parseJSONPointer } from './jsonPointer.js' import { compileJSONPointer, parseJSONPointer } from './jsonPointer.js'
import initial from 'lodash/initial.js' import initial from 'lodash/initial.js' // FIXME
import isEqual from 'lodash/isEqual.js' import isEqual from 'lodash/isEqual.js' // FIXME
/** /**
* Apply a patch to a JSON object * Apply a patch to a JSON object

67
src/utils/syncState.js Normal file
View File

@ -0,0 +1,67 @@
import {
DEFAULT_LIMIT,
STATE_EXPANDED,
STATE_LIMIT,
STATE_PROPS
} from '../constants.js'
import { isObject, isObjectOrArray } from './typeUtils.js'
import { updateProps } from './updateProps.js'
/**
* @param {JSON} document
* @param {JSON | undefined} state
* @param {Path} path
* @param {function (path: Path) : boolean} expand
* @returns {JSON | undefined}
*/
export function syncState (document, state = undefined, path, expand) {
if (isObject(document)) {
const updatedState = {}
updatedState[STATE_PROPS] = updateProps(document, state && state[STATE_PROPS])
updatedState[STATE_EXPANDED] = state
? state[STATE_EXPANDED]
: expand(path)
if (updatedState[STATE_EXPANDED]) {
Object.keys(document).forEach(key => {
const childDocument = document[key]
if (isObjectOrArray(childDocument)) {
const childState = state && state[key]
updatedState[key] = syncState(childDocument, childState, path.concat(key), expand)
}
})
}
return updatedState
}
if (Array.isArray(document)) {
const updatedState = []
// TODO: can we make the state for array a regular object { limit: 100, items: [...] }?
updatedState[STATE_LIMIT] = state
? state[STATE_LIMIT]
: DEFAULT_LIMIT
updatedState[STATE_EXPANDED] = state
? state[STATE_EXPANDED]
: expand(path)
if (updatedState[STATE_EXPANDED]) {
for (let i = 0; i < Math.min(document.length, updatedState[STATE_LIMIT]); i++) {
const childDocument = document[i]
if (isObjectOrArray(childDocument)) {
const childState = state && state[i]
updatedState[i] = syncState(childDocument, childState, path.concat(i), expand)
}
}
}
return updatedState
}
// primitive values have no state
return undefined
}

View File

@ -0,0 +1,51 @@
import {
DEFAULT_LIMIT,
STATE_EXPANDED,
STATE_LIMIT,
STATE_PROPS
} from '../constants.js'
import { syncState } from './syncState.js'
import { expect } from './testUtils.js' // FIXME: replace jest with mocha tests, or move to jest
const test = it // TODO: replace jest with mocha tests, or move to jest
test('syncState', () => {
const document = {
array: [1, 2, {c: 6}],
object: {a: 4, b: 5},
value: 'hello'
}
function expand (path) {
return path.length <= 1
}
const state = syncState(document, undefined, [], expand)
const expectedState = {}
expectedState[STATE_EXPANDED] = true
expectedState[STATE_PROPS] = [
{ 'id': '1', 'key': 'array' },
{ 'id': '2', 'key': 'object' },
{ 'id': '3', 'key': 'value' }
]
expectedState.array = []
expectedState.array[STATE_EXPANDED] = true
expectedState.array[STATE_LIMIT] = DEFAULT_LIMIT
expectedState.array[2] = {}
expectedState.array[2][STATE_EXPANDED] = false
expectedState.array[2][STATE_PROPS] = [
{ 'id': '4', 'key': 'c' } // FIXME: props should not be created because node is not expande
]
expectedState.object = {}
expectedState.object[STATE_EXPANDED] = true
expectedState.object[STATE_PROPS] = [
{ 'id': '5', 'key': 'a' },
{ 'id': '6', 'key': 'b' }
]
expect(state).toEqual(expectedState)
})
// TODO: write more unit tests for syncState

View File

@ -1,5 +1,5 @@
import { isObject } from './typeUtils.js' import { isObject } from './typeUtils.js'
import uniqueId from 'lodash/uniqueId.js' import uniqueId from 'lodash/uniqueId.js' // FIXME
export function updateProps (value, prevProps) { export function updateProps (value, prevProps) {
if (isObject(value)) { if (isObject(value)) {

View File

@ -9,7 +9,6 @@ test('updateProps (1)', () => {
const props2 = updateProps({a: 1, b: 2}, props1) const props2 = updateProps({a: 1, b: 2}, props1)
expect(props2.map(item => item.key)).toEqual(['b', 'a']) expect(props2.map(item => item.key)).toEqual(['b', 'a'])
expect(props2[0].id).toEqual('1')
}) })
test('updateProps (2)', () => { test('updateProps (2)', () => {