Skip to content

Commit

Permalink
feat(Web patches): Add JSON patching module
Browse files Browse the repository at this point in the history
  • Loading branch information
nokome committed Oct 10, 2021
1 parent af89436 commit 2002a96
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 65 deletions.
76 changes: 66 additions & 10 deletions web/src/patches/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,31 +37,31 @@ export function assert(condition: boolean, message: string): void {
}

/**
* Is a slot a string variant?
* Is a slot a name (string) variant?
*/
export function isString(slot: Slot | undefined): slot is string {
export function isName(slot: Slot | undefined): slot is string {
return typeof slot === 'string'
}

/**
* Assert that a slot is a string variant.
* Assert that a slot is a name (string) variant.
*/
export function assertString(slot: Slot | undefined): asserts slot is string {
assert(isString(slot), 'Expected string slot')
export function assertName(slot: Slot | undefined): asserts slot is string {
assert(isName(slot), 'Expected string slot')
}

/**
* Is a slot a number variant?
* Is a slot an index (integer) variant?
*/
export function isNumber(slot: Slot | undefined): slot is number {
export function isIndex(slot: Slot | undefined): slot is number {
return typeof slot === 'number'
}

/**
* Assert that a slot is a number variant.
* Assert that a slot is an index (integer) variant.
*/
export function assertNumber(slot: Slot | undefined): asserts slot is number {
assert(isNumber(slot), 'Expected number slot')
export function assertIndex(slot: Slot | undefined): asserts slot is number {
assert(isIndex(slot), 'Expected number slot')
}

/**
Expand Down Expand Up @@ -91,3 +91,59 @@ export function isAttr(node: Node | undefined): node is Attr {
export function isText(node: Node | undefined): node is Text {
return node?.nodeType === Node.TEXT_NODE
}

export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
export type JsonObject = { [key: string]: JsonValue }

/**
* Assert that a JSON value is defined.
*/
export function assertDefined(
value: JsonValue | undefined
): asserts value is JsonValue {
assert(value !== undefined, 'Expected value to be defined')
}

/**
* Assert that a JSON value is a string
*/
export function assertString(value: JsonValue): asserts value is string {
assert(typeof value === 'string', 'Expected a JSON string')
}

/**
* Is a JSON value an array?
*/
export function isArray(value: JsonValue): value is JsonArray {
return Array.isArray(value)
}

/**
* Is a JSON value an object?
*/
export function isObject(value: JsonValue): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}

/**
* Assert that a JSON value is an array
*/
export function assertArray(value: JsonValue): asserts value is JsonArray {
assert(isArray(value), 'Expected a JSON array')
}

/**
* Assert that a JSON value is an array or object
*/
export function assertArrayOrObject(
value: JsonValue
): asserts value is JsonArray | JsonObject {
assert(isArray(value) || isObject(value), 'Expected a JSON array or object')
}
14 changes: 4 additions & 10 deletions web/src/patches/dom/add.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { DomOperationAdd, Slot } from '@stencila/stencila'
import { ElementId } from '../../types'
import {
assertNumber,
assertString,
isElement,
isString,
panic,
} from '../checks'
import { assertIndex, assertName, isElement, isName, panic } from '../checks'
import { applyAdd as applyAddString } from '../string'
import { createFragment, resolveParent } from './resolve'

Expand All @@ -21,7 +15,7 @@ export function applyAdd(op: DomOperationAdd, target?: ElementId): void {
const [parent, slot] = resolveParent(address, target)

if (isElement(parent)) {
if (isString(slot)) applyAddOption(parent, slot, html)
if (isName(slot)) applyAddOption(parent, slot, html)
else applyAddVec(parent, slot, html)
} else {
applyAddText(parent, slot, html)
Expand All @@ -44,7 +38,7 @@ const ADD_ATTRIBUTES = ['id', 'value', 'rowspan', 'colspan']
* so wrap it.
*/
export function applyAddOption(node: Element, slot: Slot, html: string): void {
assertString(slot)
assertName(slot)

if (!html.startsWith('<')) {
html = `<span slot="${slot}">${html}</span>`
Expand All @@ -62,7 +56,7 @@ export function applyAddOption(node: Element, slot: Slot, html: string): void {
* Apply an `Add` operation to an element representing a `Vec`.
*/
export function applyAddVec(node: Element, slot: Slot, html: string): void {
assertNumber(slot)
assertIndex(slot)

const fragment = createFragment(html)
const children = node.childNodes
Expand Down
6 changes: 3 additions & 3 deletions web/src/patches/dom/move.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DomOperationMove, Slot } from '@stencila/stencila'
import { ElementId } from '../../types'
import { assert, assertElement, assertNumber, panic } from '../checks'
import { assert, assertElement, assertIndex, panic } from '../checks'
import { resolveParent } from './resolve'

/**
Expand Down Expand Up @@ -34,8 +34,8 @@ export function applyMoveVec(
to: Slot,
items: number
): void {
assertNumber(from)
assertNumber(to)
assertIndex(from)
assertIndex(to)

const children = elem.childNodes
assert(
Expand Down
12 changes: 6 additions & 6 deletions web/src/patches/dom/remove.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { DomOperationRemove, Slot } from '@stencila/stencila'
import { ElementId } from '../../types'
import {
isName,
assert,
assertNumber,
assertString,
assertIndex,
assertName,
isAttr,
isElement,
isString,
panic,
} from '../checks'
import { applyRemove as applyRemoveString } from '../string'
Expand All @@ -23,7 +23,7 @@ export function applyRemove(op: DomOperationRemove, target?: ElementId): void {
const [parent, slot] = resolveParent(address, target)

if (isElement(parent)) {
if (isString(slot)) applyRemoveOption(parent, slot, items)
if (isName(slot)) applyRemoveOption(parent, slot, items)
else applyRemoveVec(parent, slot, items)
} else applyRemoveText(parent, slot, items)
}
Expand All @@ -36,7 +36,7 @@ export function applyRemoveOption(
slot: Slot,
items: number
): void {
assertString(slot)
assertName(slot)
assert(
items === 1,
`Unexpected remove items ${items} for option slot '${slot}'`
Expand All @@ -52,7 +52,7 @@ export function applyRemoveOption(
* Apply a `Remove` operation to a `Vec` slot
*/
export function applyRemoveVec(node: Element, slot: Slot, items: number): void {
assertNumber(slot)
assertIndex(slot)

const children = node.childNodes
assert(
Expand Down
12 changes: 6 additions & 6 deletions web/src/patches/dom/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { DomOperationReplace, Slot } from '@stencila/stencila'
import { ElementId } from '../../types'
import {
assert,
assertNumber,
assertString,
assertIndex,
assertName,
isAttr,
isElement,
isString,
isName,
panic,
} from '../checks'
import { createFragment, resolveParent, resolveSlot } from './resolve'
Expand All @@ -26,7 +26,7 @@ export function applyReplace(
const [parent, slot] = resolveParent(address, target)

if (isElement(parent)) {
if (isString(slot)) applyReplaceOption(parent, slot, items, html)
if (isName(slot)) applyReplaceOption(parent, slot, items, html)
else applyReplaceVec(parent, slot, items, html)
} else applyReplaceText(parent, slot, items, html)
}
Expand All @@ -40,7 +40,7 @@ export function applyReplaceOption(
items: number,
html: string
): void {
assertString(slot)
assertName(slot)
assert(
items === 1,
`Unexpected replace items ${items} for option slot '${slot}'`
Expand All @@ -63,7 +63,7 @@ export function applyReplaceVec(
items: number,
html: string
): void {
assertNumber(slot)
assertIndex(slot)

const fragment = createFragment(html)
const children = node.childNodes
Expand Down
4 changes: 2 additions & 2 deletions web/src/patches/dom/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Address, DomOperation, Slot } from '@stencila/stencila'
import { StencilaElement } from '../../components/base'
import { ElementId } from '../../types'
import { assertElement, isElement, isString, isText, panic } from '../checks'
import { assertElement, isElement, isName, isText, panic } from '../checks'

/**
* Resolve the target of a patch.
Expand Down Expand Up @@ -48,7 +48,7 @@ export function resolveSlot(
parent: Element,
slot: Slot
): Element | Attr | Text {
if (isString(slot)) {
if (isName(slot)) {
// Select the first descendant element matching the slot name.
// It is proposed that `data-prop` replace `data-itemprop`.
// This currently allows for all options.
Expand Down
87 changes: 87 additions & 0 deletions web/src/patches/json/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @ts-nocheck because the `DomOperationAdd` has incorrect type for `json`

import { applyAdd, applyRemove, applyReplace } from '.'

test('applyAdd', () => {
const value = { string: '', array: [], object: {} }

applyAdd(value, { type: 'Add', address: ['string', 0], json: '' })
expect(value.string).toEqual('')

applyAdd(value, { type: 'Add', address: ['string', 0], json: '12' })
expect(value.string).toEqual('12')

applyAdd(value, { type: 'Add', address: ['string', 1], json: '34' })
expect(value.string).toEqual('1342')

applyAdd(value, { type: 'Add', address: ['array', 0], json: [] })
expect(value.array).toEqual([])

applyAdd(value, { type: 'Add', address: ['array', 0], json: [1, 2] })
expect(value.array).toEqual([1, 2])

applyAdd(value, { type: 'Add', address: ['array', 1], json: [3, 4] })
expect(value.array).toEqual([1, 3, 4, 2])

applyAdd(value, { type: 'Add', address: ['object', 'a'], json: true })
expect(value.object).toEqual({ a: true })

applyAdd(value, { type: 'Add', address: ['object', 'b'], json: 'foo' })
expect(value.object).toEqual({ a: true, b: 'foo' })

applyAdd(value, { type: 'Add', address: ['object', 'c'], json: 42 })
expect(value.object).toEqual({ a: true, b: 'foo', c: 42 })

applyAdd(value, { type: 'Add', address: ['object', 'b', 3], json: 'd' })
expect(value.object).toEqual({ a: true, b: 'food', c: 42 })
})

test('applyRemove', () => {
const value = { string: 'abcd', array: [1, 2, 3, 4], object: { a: 1, b: 2 } }

applyRemove(value, { type: 'Remove', address: ['string', 0], items: 1 })
expect(value.string).toEqual('bcd')

applyRemove(value, { type: 'Remove', address: ['string', 1], items: 2 })
expect(value.string).toEqual('b')

applyRemove(value, { type: 'Remove', address: ['array', 0], items: 1 })
expect(value.array).toEqual([2, 3, 4])

applyRemove(value, { type: 'Remove', address: ['array', 2], items: 1 })
expect(value.array).toEqual([2, 3])

applyRemove(value, { type: 'Remove', address: ['object', 'a'], items: 1 })
expect(value.object).toEqual({ b: 2 })

applyRemove(value, { type: 'Remove', address: ['object', 'b'], items: 1 })
expect(value.object).toEqual({})
})

test('applyReplace', () => {
const value = { string: 'abcd', array: [1, 2, 3, 4], object: { a: 1, b: 2 } }

applyReplace(value, {
type: 'Replace',
address: ['string', 1],
items: 2,
json: 'ef',
})
expect(value.string).toEqual('aefd')

applyReplace(value, {
type: 'Replace',
address: ['array', 1],
items: 2,
json: [5, 6, 7],
})
expect(value.array).toEqual([1, 5, 6, 7, 4])

applyReplace(value, {
type: 'Replace',
address: ['object', 'a'],
items: 1,
json: false,
})
expect(value.object).toEqual({ a: false, b: 2 })
})

0 comments on commit 2002a96

Please sign in to comment.