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 Node from './JSONNode.svelte'
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 { flattenSearch, search } from './utils/search.js'
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 { syncState } from './utils/syncState.js'
let divContents
@ -28,15 +29,20 @@
console.timeEnd('render')
})
export let json = {}
export let onChangeJson = () => {
export let json = {} // TODO: rename 'json' to 'document'
export let onChangeJson = () => {}
function expand (path) {
return path.length < 1
}
const INITIAL_STATE = {
[STATE_EXPANDED]: true
}
let state
let state = INITIAL_STATE
$: {
console.time('syncState')
state = syncState(json, state, [], expand)
console.timeEnd('syncState')
}
let showSearch = false
let searchText = ''
@ -54,39 +60,85 @@
export function set(newJson) {
json = newJson
state = INITIAL_STATE
state = undefined
history.clear()
}
function applyPatch (operations) {
const patchResult = immutableJSONPatch(json, operations)
json = patchResult.json
state = immutableJSONPatch(state, operations).json
return patchResult
}
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({
undo: patchResult.revert,
redo: operations
json = documentPatchResult.json
state = statePatchResult.json
// 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 {
json,
error: patchResult.error,
undo: patchResult.revert,
error: documentPatchResult.error,
undo: documentPatchResult.revert,
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) {
return search(null, json, searchText)
}
@ -177,32 +229,13 @@
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
* @param {Path} path
* @param {boolean} expanded
*/
function handleExpand (path, expanded) {
console.log('handleExpand', path, expanded)
state = setIn(state, path.concat(STATE_EXPANDED), expanded)
}
@ -215,15 +248,6 @@
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
* @param {Path} path
@ -341,7 +365,6 @@
onPatch={handlePatch}
onExpand={handleExpand}
onLimit={handleLimit}
onUpdateProps={handleUpdateProps}
/>
<div class='bottom'></div>
</div>

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { STATE_EXPANDED, STATE_LIMIT } from '../constants.js'
import { isObjectOrArray } from './typeUtils.js'
/**
@ -19,7 +20,17 @@ import { isObjectOrArray } from './typeUtils.js'
export function shallowClone (value) {
if (Array.isArray(value)) {
// 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') {
// copy object properties

View File

@ -6,8 +6,8 @@ import {
setIn
} from './immutabilityHelpers.js'
import { compileJSONPointer, parseJSONPointer } from './jsonPointer.js'
import initial from 'lodash/initial.js'
import isEqual from 'lodash/isEqual.js'
import initial from 'lodash/initial.js' // FIXME
import isEqual from 'lodash/isEqual.js' // FIXME
/**
* 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 uniqueId from 'lodash/uniqueId.js'
import uniqueId from 'lodash/uniqueId.js' // FIXME
export function updateProps (value, prevProps) {
if (isObject(value)) {

View File

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