From 1e1e85cae38ac1f62ba2307f135217728b9e4e6b Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Fri, 5 Jul 2024 11:53:23 +0200 Subject: [PATCH] feat: implemented a powerful API for expand and collapse (#458) BREAKING CHANGE: The API of the `expand` function is changed from `expand(callback)` to `expand(path, callback)`, and can't be used anymore for collapsing nodes. Instead, use the `collapse(path)` method for that. --- README.md | 40 +- src/lib/components/JSONEditor.svelte | 10 +- .../components/modes/JSONEditorRoot.svelte | 12 +- .../modes/tablemode/TableMode.svelte | 23 +- .../components/modes/treemode/TreeMode.svelte | 78 ++- src/lib/index.ts | 3 + src/lib/logic/actions.ts | 18 +- src/lib/logic/documentState.test.ts | 470 +++++++++++++++--- src/lib/logic/documentState.ts | 407 ++++++++------- src/lib/logic/selection.test.ts | 14 + src/lib/logic/selection.ts | 3 +- src/lib/types.ts | 2 +- 12 files changed, 707 insertions(+), 373 deletions(-) diff --git a/README.md b/README.md index aa00732c..e7d7c2d6 100644 --- a/README.md +++ b/README.md @@ -404,7 +404,7 @@ Invoked when the mode is changed. #### onClassName ```ts -onClassName(path: Path, value: any): string | undefined +onClassName(path: JSONPath, value: any): string | undefined ``` Add a custom class name to specific nodes, based on their path and/or value. Note that in the custom class, you can override CSS variables like `--jse-contents-background-color` to change the styling of a node, like the background color. Relevant variables are: @@ -712,14 +712,33 @@ editor.updateProps({ #### expand ```ts -JSONEditor.prototype.expand([callback: (path: Path) => boolean]): Promise +JSONEditor.prototype.expand(path: JSONPath, callback?: (relativePath: JSONPath) => boolean = expandSelf): Promise ``` -Expand or collapse paths in the editor. The `callback` determines which paths will be expanded. If no `callback` is provided, all paths will be expanded. It is only possible to expand a path when all of its parent paths are expanded too. Examples: +Expand or collapse paths in the editor. All nodes along the provided `path` will be expanded and become visible (rendered). So for example collapsed sections of an array will be expanded. Using the optional `callback`, the node itself and some or all of its nested child nodes can be expanded too. The `callback` function only iterates over the visible sections of an array and not over any of the collapsed sections. By default, the first 100 items of an array are visible and rendered. -- `editor.expand(path => true)` expand all -- `editor.expand(path => false)` collapse all -- `editor.expand(path => path.length < 2)` expand all paths up to 2 levels deep +Examples: + +- `editor.expand([], () => true)` expand all +- `editor.expand([], relativePath => relativePath.length < 2)` expand all paths up to 2 levels deep +- `editor.expand(['array', '204'])` expand the root object, the array in this object, and the 204th item in the array. +- `editor.expand(['array', '204'], () => false)` expand the root object, the array in this object, but not the 204th item itself. +- `editor.expand(['array', '204'], relativePath => relativePath.length < 2)` expand the root object, the array in this object, and expand the 204th array item and all of its child's up to a depth of max 2 levels. + +The library exports a couple of utility functions for commonly used `callback` functions: + +- `expandAll`: recursively expand all nested objects and arrays. +- `expandNone`: expand nothing, also not the root object or array. +- `expandSelf`: expand the root array or object. This is the default for the `callback` parameter. +- `expandMinimal`: expand the root array or object, and in case of an array, expand the first array item. + +### collapse + +```ts +JSONEditor.prototype.collapse(path: JSONPath, recursive?: boolean = false): Promise +``` + +Collapse a path in the editor. When `recursive` is `true`, all nested objects and arrays will be collapsed too. The default value of `recursive` is `false`. #### transform @@ -732,7 +751,7 @@ Programmatically trigger clicking of the transform button in the main menu, open #### scrollTo ```ts -JSONEditor.prototype.scrollTo(path: Path): Promise +JSONEditor.prototype.scrollTo(path: JSONPath): Promise ``` Scroll the editor vertically such that the specified path comes into view. Only applicable to modes `tree` and `table`. The path will be expanded when needed. The returned Promise is resolved after scrolling is finished. @@ -740,7 +759,7 @@ Scroll the editor vertically such that the specified path comes into view. Only #### findElement ```ts -JSONEditor.prototype.findElement(path: Path) +JSONEditor.prototype.findElement(path: JSONPath) ``` Find the DOM element of a given path. Returns `null` when not found. @@ -821,6 +840,11 @@ The library exports a set of utility functions. The exact definitions of those f - `toTextContent` - `toJSONContent` - `estimateSerializedSize` +- Expand: + - `expandAll` + - `expandMinimal` + - `expandNone` + - `expandSelf` - Selection: - `isValueSelection` - `isKeySelection` diff --git a/src/lib/components/JSONEditor.svelte b/src/lib/components/JSONEditor.svelte index 7ef4010c..bf4fbd4e 100644 --- a/src/lib/components/JSONEditor.svelte +++ b/src/lib/components/JSONEditor.svelte @@ -211,8 +211,14 @@ await tick() // await rerender } - export async function expand(callback?: OnExpand): Promise { - refJSONEditorRoot.expand(callback) + export async function expand(path: JSONPath, callback?: OnExpand): Promise { + refJSONEditorRoot.expand(path, callback) + + await tick() // await rerender + } + + export async function collapse(path: JSONPath, recursive = false): Promise { + refJSONEditorRoot.collapse(path, recursive) await tick() // await rerender } diff --git a/src/lib/components/modes/JSONEditorRoot.svelte b/src/lib/components/modes/JSONEditorRoot.svelte index c25874ed..2c8e7bbd 100644 --- a/src/lib/components/modes/JSONEditorRoot.svelte +++ b/src/lib/components/modes/JSONEditorRoot.svelte @@ -147,14 +147,22 @@ throw new Error(`Method patch is not available in mode "${mode}"`) } - export function expand(callback?: OnExpand): void { + export function expand(path: JSONPath, callback?: OnExpand): void { if (refTreeMode) { - return refTreeMode.expand(callback) + return refTreeMode.expand(path, callback) } else { throw new Error(`Method expand is not available in mode "${mode}"`) } } + export function collapse(path: JSONPath, recursive: boolean): void { + if (refTreeMode) { + return refTreeMode.collapse(path, recursive) + } else { + throw new Error(`Method collapse is not available in mode "${mode}"`) + } + } + /** * Open the transform modal */ diff --git a/src/lib/components/modes/tablemode/TableMode.svelte b/src/lib/components/modes/tablemode/TableMode.svelte index 781431f9..956073fc 100644 --- a/src/lib/components/modes/tablemode/TableMode.svelte +++ b/src/lib/components/modes/tablemode/TableMode.svelte @@ -83,8 +83,6 @@ import { createDocumentState, documentStatePatch, - expandMinimal, - expandWithCallback, getEnforceString, getInRecursiveState, setInDocumentState, @@ -1369,12 +1367,7 @@ const previousContent = { json, text } const previousState = { json, documentState, selection, sortedColumn, text, textIsRepaired } - const updatedState = expandWithCallback( - json, - syncDocumentState(updatedJson, documentState), - [], - expandMinimal - ) + const updatedState = syncDocumentState(updatedJson, documentState) const callback = typeof afterPatch === 'function' @@ -1411,24 +1404,14 @@ try { json = parseMemoizeOne(updatedText) - documentState = expandWithCallback( - json, - syncDocumentState(json, documentState), - [], - expandMinimal - ) + documentState = syncDocumentState(json, documentState) text = undefined textIsRepaired = false parseError = undefined } catch (err) { try { json = parseMemoizeOne(jsonrepair(updatedText)) - documentState = expandWithCallback( - json, - syncDocumentState(json, documentState), - [], - expandMinimal - ) + documentState = syncDocumentState(json, documentState) text = updatedText textIsRepaired = true parseError = undefined diff --git a/src/lib/components/modes/treemode/TreeMode.svelte b/src/lib/components/modes/treemode/TreeMode.svelte index 4cdd81ae..e9eef6ef 100644 --- a/src/lib/components/modes/treemode/TreeMode.svelte +++ b/src/lib/components/modes/treemode/TreeMode.svelte @@ -29,12 +29,11 @@ documentStatePatch, expandAll, expandMinimal, + expandNone, expandPath, - expandRecursive, expandSection, - expandSingleItem, - expandWithCallback, - getDefaultExpand, + expandSelf, + expandSmart, getEnforceString, setInDocumentState, syncDocumentState @@ -136,13 +135,13 @@ OnTransformModal, ParseError, PastedJson, - SearchResults, - ValidationErrors, SearchResultDetails, + SearchResults, Section, TransformModalOptions, TreeModeContext, ValidationError, + ValidationErrors, Validator, ValueNormalization } from '$lib/types' @@ -301,7 +300,7 @@ } async function handleFocusSearch(path: JSONPath) { - documentState = expandPath(json, documentState, path) + documentState = expandPath(json, documentState, path, expandNone) await scrollTo(path) } @@ -325,11 +324,22 @@ }) let historyState = history.getState() - export function expand(callback: OnExpand = expandAll) { + export function expand(path: JSONPath, callback: OnExpand = expandSelf) { debug('expand') - // FIXME: clear the expanded state and visible sections (else you can't collapse anything using the callback) - documentState = expandWithCallback(json, documentState, [], callback) + documentState = expandPath(json, documentState, path, callback) + } + + export function collapse(path: JSONPath, recursive: boolean) { + documentState = collapsePath(json, documentState, path, recursive) + + if (selection) { + // check whether the selection is still visible and not collapsed + if (isSelectionInsidePath(selection, path)) { + // remove selection when not visible anymore + updateSelection(undefined) + } + } } // two-way binding of externalContent and internal json and text ( @@ -511,7 +521,7 @@ function expandWhenNotInitialized(json: unknown) { if (!documentStateInitialized) { documentStateInitialized = true - documentState = createDocumentState({ json, expand: getDefaultExpand(json) }) + documentState = expandSmart(json, documentState, []) } } @@ -843,7 +853,7 @@ // expand extracted object/array const path: JSONPath = [] return { - state: expandRecursive(patchedJson, patchedState, path) + state: expandSmart(patchedJson, patchedState, path) } } @@ -911,7 +921,7 @@ // expand converted object/array return { state: selection - ? expandRecursive(patchedJson, patchedState, getFocusPath(selection)) + ? expandSmart(patchedJson, patchedState, getFocusPath(selection)) : documentState } }) @@ -1074,7 +1084,7 @@ handlePatch(operations, (patchedJson, patchedState) => ({ // expand the newly replaced array and select it - state: expandRecursive(patchedJson, patchedState, rootPath), + state: expandSmart(patchedJson, patchedState, rootPath), selection: createValueSelection(rootPath, false) })) }, @@ -1128,7 +1138,7 @@ handlePatch(operations, (patchedJson, patchedState) => ({ // expand the newly replaced array and select it - state: expandRecursive(patchedJson, patchedState, rootPath), + state: expandSmart(patchedJson, patchedState, rootPath), selection: createValueSelection(rootPath, false) })) } @@ -1184,7 +1194,7 @@ * Expand the path when needed. */ export async function scrollTo(path: JSONPath, scrollToWhenVisible = true): Promise { - documentState = expandPath(json, documentState, path) + documentState = expandPath(json, documentState, path, expandNone) await tick() // await rerender (else the element we want to scroll to does not yet exist) const elem = findElement(path) @@ -1302,7 +1312,7 @@ const previousContent = { json, text } const previousState = { documentState, selection, json, text, textIsRepaired } - const updatedState = expandWithCallback( + const updatedState = expandPath( json, syncDocumentState(updatedJson, documentState), [], @@ -1342,24 +1352,14 @@ try { json = parseMemoizeOne(updatedText) - documentState = expandWithCallback( - json, - syncDocumentState(json, documentState), - [], - expandMinimal - ) + documentState = expandPath(json, syncDocumentState(json, documentState), [], expandMinimal) text = undefined textIsRepaired = false parseError = undefined } catch (err) { try { json = parseMemoizeOne(jsonrepair(updatedText)) - documentState = expandWithCallback( - json, - syncDocumentState(json, documentState), - [], - expandMinimal - ) + documentState = expandPath(json, syncDocumentState(json, documentState), [], expandMinimal) text = updatedText textIsRepaired = true parseError = undefined @@ -1398,28 +1398,16 @@ /** * Toggle expanded state of a node * @param path The path to be expanded - * @param expanded True to expand, false to collapse + * @param expanded True if currently expanded, false when currently collapsed * @param [recursive=false] Only applicable when expanding */ function handleExpand(path: JSONPath, expanded: boolean, recursive = false): void { debug('handleExpand', { path, expanded, recursive }) if (expanded) { - if (recursive) { - documentState = expandWithCallback(json, documentState, path, expandAll) - } else { - documentState = expandSingleItem(json, documentState, path) - } + expand(path, recursive ? expandAll : expandSelf) } else { - documentState = collapsePath(json, documentState, path) - - if (selection) { - // check whether the selection is still visible and not collapsed - if (isSelectionInsidePath(selection, path)) { - // remove selection when not visible anymore - updateSelection(undefined) - } - } + collapse(path, recursive) } // set focus to the hidden input, so we can capture quick keys like Ctrl+X, Ctrl+C, Ctrl+V @@ -1802,7 +1790,7 @@ handlePatch(operations, (patchedJson, patchedState) => { return { - state: expandRecursive(patchedJson, patchedState, path) + state: expandSmart(patchedJson, patchedState, path) } }) diff --git a/src/lib/index.ts b/src/lib/index.ts index 55faa2d3..58553aae 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -34,6 +34,9 @@ export { estimateSerializedSize } from './utils/jsonUtils.js' +// expand +export { expandAll, expandMinimal, expandNone, expandSelf } from './logic/documentState' + // selection export { isValueSelection, diff --git a/src/lib/logic/actions.ts b/src/lib/logic/actions.ts index 8302452d..8217a017 100644 --- a/src/lib/logic/actions.ts +++ b/src/lib/logic/actions.ts @@ -40,12 +40,7 @@ import { parsePath } from 'immutable-json-patch' import { isObject, isObjectOrArray } from '$lib/utils/typeUtils.js' -import { - expandAll, - expandPath, - expandRecursive, - expandWithCallback -} from '$lib/logic/documentState.js' +import { expandAll, expandSmart, expandPath, expandNone } from '$lib/logic/documentState.js' import { initial, isEmpty, last } from 'lodash-es' import { insertActiveElementContents } from '$lib/utils/domUtils.js' import { fromTableCellPosition, toTableCellPosition } from '$lib/logic/table.js' @@ -158,7 +153,7 @@ export function onPaste({ ) .forEach((operation) => { const path = parsePath(json, operation.path) - updatedState = expandRecursive(patchedJson, updatedState, path) + updatedState = expandSmart(patchedJson, updatedState, path) }) return { @@ -173,7 +168,7 @@ export function onPaste({ if (patchedJson) { const path: JSONPath = [] return { - state: expandRecursive(patchedJson, patchedState, path) + state: expandSmart(patchedJson, patchedState, path) } } @@ -476,7 +471,7 @@ export function onInsert({ if (isObjectOrArray(newValue)) { return { - state: expandWithCallback(patchedJson, patchedState, path, expandAll), + state: expandPath(patchedJson, patchedState, path, expandAll), selection: selectInside ? createInsideSelection(path) : patchedSelection } } @@ -486,8 +481,7 @@ export function onInsert({ const parent = !isEmpty(path) ? getIn(patchedJson, initial(path)) : undefined return { - // expandPath is invoked to make sure that visibleSections is extended when needed - state: expandPath(patchedJson, patchedState, path), + state: expandPath(patchedJson, patchedState, path, expandNone), selection: isObject(parent) ? createKeySelection(path, true) : createValueSelection(path, true) @@ -512,7 +506,7 @@ export function onInsert({ const path: JSONPath = [] onReplaceJson(newValue, (patchedJson, patchedState) => ({ - state: expandRecursive(patchedJson, patchedState, path), + state: expandSmart(patchedJson, patchedState, path), selection: isObjectOrArray(newValue) ? createInsideSelection(path) : createValueSelection(path, true) diff --git a/src/lib/logic/documentState.test.ts b/src/lib/logic/documentState.test.ts index fcb3f5f0..8e6017d0 100644 --- a/src/lib/logic/documentState.test.ts +++ b/src/lib/logic/documentState.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest' import { flatMap, isEqual, range, times } from 'lodash-es' import { ARRAY_SECTION_SIZE, DEFAULT_VISIBLE_SECTIONS } from '../constants.js' import { + collapsePath, createArrayDocumentState, createDocumentState, createObjectDocumentState, @@ -10,9 +11,12 @@ import { documentStateFactory, documentStatePatch, ensureRecursiveState, + expandAll, + expandNone, expandPath, expandSection, - expandWithCallback, + expandSelf, + expandSmart, forEachVisibleIndex, getEnforceString, getVisibleCaretPositions, @@ -24,9 +28,11 @@ import { updateInDocumentState } from './documentState.js' import { - CaretType, type ArrayDocumentState, + CaretType, type DocumentState, + type ObjectDocumentState, + type OnExpand, type VisibleSection } from '$lib/types.js' import { deleteIn, getIn, type JSONPatchDocument, setIn, updateIn } from 'immutable-json-patch' @@ -46,8 +52,7 @@ describe('documentState', () => { const expected: DocumentState = { type: 'array', expanded: false, - // eslint-disable-next-line no-sparse-arrays - items: [, { type: 'value' }], + items: initArray([1, { type: 'value' }]), visibleSections: DEFAULT_VISIBLE_SECTIONS } @@ -68,8 +73,7 @@ describe('documentState', () => { const expected: DocumentState = { type: 'array', expanded: true, - // eslint-disable-next-line no-sparse-arrays - items: [, { type: 'value' }], + items: initArray([1, { type: 'value' }]), visibleSections: DEFAULT_VISIBLE_SECTIONS } @@ -123,8 +127,7 @@ describe('documentState', () => { array: { type: 'array', expanded: false, - // eslint-disable-next-line no-sparse-arrays - items: [, { type: 'value' }], + items: initArray([1, { type: 'value' }]), visibleSections: DEFAULT_VISIBLE_SECTIONS } } @@ -162,7 +165,7 @@ describe('documentState', () => { type: 'object', expanded: true, properties: { - c: { type: 'value', enforceString: false } + c: { type: 'value', enforceString: true } } } assert.deepStrictEqual(syncDocumentState({ a: 1, b: 2, c: 3 }, state), state) @@ -234,16 +237,14 @@ describe('documentState', () => { const state: DocumentState = { type: 'array', expanded: true, - // eslint-disable-next-line no-sparse-arrays - items: [, { type: 'value' }, { type: 'value' }], + items: initArray([1, { type: 'value' }], [2, { type: 'value' }]), visibleSections: DEFAULT_VISIBLE_SECTIONS } const expected: DocumentState = { type: 'array', expanded: true, - // eslint-disable-next-line no-sparse-arrays - items: [, { type: 'value' }], + items: initArray([1, { type: 'value' }]), visibleSections: DEFAULT_VISIBLE_SECTIONS } @@ -300,7 +301,6 @@ describe('documentState', () => { type: 'array', expanded: true, visibleSections: DEFAULT_VISIBLE_SECTIONS, - // eslint-disable-next-line no-sparse-arrays items: [{ type: 'value' }, { type: 'value' }, { type: 'value' }, { type: 'value' }] } ], @@ -315,7 +315,6 @@ describe('documentState', () => { type: 'array', expanded: true, visibleSections: DEFAULT_VISIBLE_SECTIONS, - // eslint-disable-next-line no-sparse-arrays items: [{ type: 'value' }, { type: 'value' }, { type: 'value' }] } ], @@ -327,7 +326,6 @@ describe('documentState', () => { const expected2: DocumentState = { type: 'array', expanded: true, - // eslint-disable-next-line no-sparse-arrays items: [], visibleSections: DEFAULT_VISIBLE_SECTIONS } @@ -415,17 +413,17 @@ describe('documentState', () => { expect(toRecursiveStatePath(json, ['bar', '2'])).toEqual(['properties', 'bar', 'items', '2']) }) - describe('expandWithCallback', () => { + describe('expandPath with callback', () => { const json = { array: [1, 2, { c: 6 }], object: { a: 4, b: 5 }, value: 'hello' } - const expandedState = createDocumentState({ json }) + const documentState = createDocumentState({ json }) test('should fully expand a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, [], () => true), + expandPath(json, documentState, [], () => true), { type: 'object', expanded: true, @@ -434,8 +432,7 @@ describe('documentState', () => { type: 'array', expanded: true, visibleSections: DEFAULT_VISIBLE_SECTIONS, - // eslint-disable-next-line no-sparse-arrays - items: [, , { type: 'object', expanded: true, properties: {} }] + items: initArray([2, { type: 'object', expanded: true, properties: {} }]) }, object: { type: 'object', @@ -449,9 +446,9 @@ describe('documentState', () => { test('should expand a nested item of a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, ['array'], (path) => isEqual(path, ['array'])), + expandPath(json, documentState, ['array'], (relativePath) => isEqual(relativePath, [])), { - expanded: false, + expanded: true, properties: { array: { type: 'array', @@ -467,9 +464,9 @@ describe('documentState', () => { test('should expand a nested item of a json document starting without state', () => { assert.deepStrictEqual( - expandWithCallback(json, undefined, ['array'], (path) => isEqual(path, ['array'])), + expandPath(json, undefined, ['array'], (relativePath) => isEqual(relativePath, [])), { - expanded: false, + expanded: true, properties: { array: { type: 'array', @@ -485,16 +482,15 @@ describe('documentState', () => { test('should expand a part of a json document recursively', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, ['array'], () => true), + expandPath(json, documentState, ['array'], () => true), { - expanded: false, + expanded: true, properties: { array: { type: 'array', expanded: true, visibleSections: DEFAULT_VISIBLE_SECTIONS, - // eslint-disable-next-line no-sparse-arrays - items: [, , { expanded: true, properties: {}, type: 'object' }] + items: initArray([2, { expanded: true, properties: {}, type: 'object' }]) } }, type: 'object' @@ -504,7 +500,7 @@ describe('documentState', () => { test('should partially expand a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, [], (path) => path.length <= 1), + expandPath(json, documentState, [], (relativePath) => relativePath.length <= 1), { expanded: true, properties: { @@ -521,9 +517,37 @@ describe('documentState', () => { ) }) + test('should leave the documentState untouched (immutable) when there are no changes', () => { + const expected: DocumentState = { + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [] + }, + object: { type: 'object', expanded: true, properties: {} } + }, + type: 'object' + } + + const callback: OnExpand = (relativePath) => relativePath.length <= 1 + const actual = expandPath(json, expected, [], callback) as ObjectDocumentState + const actualArray = actual.properties.array as ArrayDocumentState + const expectedArray = expected.properties.array as ArrayDocumentState + + assert.deepStrictEqual(actual, expected) + assert.strictEqual(actual, expected) + assert.strictEqual(actual.properties, expected.properties) + assert.strictEqual(actualArray, expectedArray) + assert.strictEqual(actualArray.items, expectedArray.items) + assert.strictEqual(actualArray.visibleSections, expectedArray.visibleSections) + }) + test('should expand the root of a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, [], (path) => path.length === 0), + expandPath(json, documentState, [], (relativePath) => relativePath.length === 0), { expanded: true, properties: {}, @@ -532,9 +556,28 @@ describe('documentState', () => { ) }) + test('should expand a nested object', () => { + // Without callback, will not expand the nested object itself + assert.deepStrictEqual(expandPath(json, documentState, ['object'], expandNone), { + expanded: true, + properties: { + object: { type: 'object', expanded: false, properties: {} } + }, + type: 'object' + }) + + assert.deepStrictEqual(expandPath(json, documentState, ['object'], expandSelf), { + type: 'object', + expanded: true, + properties: { + object: { type: 'object', expanded: true, properties: {} } + } + }) + }) + test('should not traverse non-expanded nodes', () => { assert.deepStrictEqual( - expandWithCallback(json, expandedState, [], (path) => path.length > 0), + expandPath(json, documentState, [], (relativePath) => relativePath.length > 0), { expanded: false, properties: {}, @@ -872,7 +915,6 @@ describe('documentState', () => { }, members: { expanded: true, - // eslint-disable-next-line no-sparse-arrays items: [ { expanded: true, properties: {}, type: 'object' }, undefined, // ideally, this should be an empty item, not undefined @@ -1034,8 +1076,11 @@ describe('documentState', () => { assert.deepStrictEqual(res.json, setIn(json, ['members', '1'], 42)) assert.deepStrictEqual( res.state, - // eslint-disable-next-line no-sparse-arrays - setIn(documentState, ['properties', 'members', 'items'], [items[0], , items[2]]) + setIn( + documentState, + ['properties', 'members', 'items'], + initArray([0, items[0]], [2, items[2]]) + ) ) }) @@ -1412,7 +1457,7 @@ describe('documentState', () => { }) }) - describe('expandPath', () => { + describe('expandPath without callback', () => { const json = { array: [1, 2, { c: 6 }], object: { a: 4, b: 5, nested: { c: 6 } }, @@ -1420,62 +1465,101 @@ describe('documentState', () => { } test('should expand root path', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), []), { + assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), [], expandSelf), { type: 'object', - expanded: false, + expanded: true, properties: {} }) }) test('should expand an array', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), ['array']), { - type: 'object', - expanded: true, - properties: {} - }) + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['array'], expandNone), + { + type: 'object', + expanded: true, + properties: { + array: { + expanded: false, + items: [], + type: 'array', + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + } + } + ) }) test('should expand an object inside an array', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), ['array', '2']), { - type: 'object', - expanded: true, - properties: { - array: { - type: 'array', - expanded: true, - items: [], - visibleSections: DEFAULT_VISIBLE_SECTIONS + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['array', '2'], expandNone), + { + type: 'object', + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + items: initArray([2, { type: 'object', expanded: false, properties: {} }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } } } - }) + ) }) test('should not expand a value (only objects and arrays)', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), ['array', '0']), { - type: 'object', - expanded: true, - properties: { - array: { - type: 'array', - expanded: true, - items: [], - visibleSections: DEFAULT_VISIBLE_SECTIONS + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['array', '0'], expandAll), + { + type: 'object', + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + items: [{ type: 'value' }], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } } } - }) + ) + }) + + test('should not expand the end node of the path without callback', () => { + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['array', '2'], expandNone), + { + type: 'object', + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + items: initArray([2, { type: 'object', expanded: false, properties: {} }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + } + } + ) }) test('should expand an object', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), ['object']), { - type: 'object', - expanded: true, - properties: {} - }) + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['object'], expandSelf), + { + type: 'object', + expanded: true, + properties: { + object: { expanded: true, properties: {}, type: 'object' } + } + } + ) }) test('should expand a nested object', () => { assert.deepStrictEqual( - expandPath(json, createDocumentState({ json }), ['object', 'nested']), + expandPath(json, createDocumentState({ json }), ['object', 'nested'], expandNone), { type: 'object', expanded: true, @@ -1483,7 +1567,9 @@ describe('documentState', () => { object: { type: 'object', expanded: true, - properties: {} + properties: { + nested: { expanded: false, properties: {}, type: 'object' } + } } } } @@ -1492,11 +1578,11 @@ describe('documentState', () => { test('should expand visible section of an array if needed', () => { const json = { - largeArray: range(0, 500).map((index) => ({ id: index })) + largeArray: range(0, 300).map((index) => ({ id: index })) } assert.deepStrictEqual( - expandPath(json, createDocumentState({ json }), ['largeArray', '120']), + expandPath(json, createDocumentState({ json }), ['largeArray', '120'], expandNone), { type: 'object', expanded: true, @@ -1504,13 +1590,228 @@ describe('documentState', () => { largeArray: { type: 'array', expanded: true, - items: [], + items: initArray([120, { type: 'object', expanded: false, properties: {} }]), visibleSections: [{ start: 0, end: 200 }] } } } ) }) + + test('should leave the documentState untouched (immutable) when already expanded section of an array if needed', () => { + const json = { + largeArray: range(0, 300).map((index) => ({ id: index })) + } + + const expected: DocumentState = { + type: 'object', + expanded: true, + properties: { + largeArray: { + type: 'array', + expanded: true, + items: initArray([120, { type: 'object', expanded: false, properties: {} }]), + visibleSections: [{ start: 0, end: 200 }] + } + } + } + + const actual = expandPath( + json, + expected, + ['largeArray', '120'], + expandNone + ) as ObjectDocumentState + const actualLargeArray = actual.properties.largeArray as ArrayDocumentState + const expectedLargeArray = expected.properties.largeArray as ArrayDocumentState + + assert.deepStrictEqual(actual, expected) + assert.strictEqual(actual, expected) + assert.strictEqual(actual.properties, expected.properties) + assert.strictEqual(actualLargeArray, expectedLargeArray) + assert.strictEqual(actualLargeArray.items, expectedLargeArray.items) + assert.strictEqual(actualLargeArray.visibleSections, expectedLargeArray.visibleSections) + }) + }) + + describe('expandSmart', () => { + const array = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] + const object = { a: { id: 1 }, b: { id: 2 }, c: { id: 3 }, d: { id: 4 }, e: { id: 5 } } + + test('should fully expand a small document', () => { + assert.deepStrictEqual(expandSmart(array, undefined, [], 100), { + expanded: true, + items: [ + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' } + ], + type: 'array', + visibleSections: [{ end: 100, start: 0 }] + }) + }) + + test('should expand only the first array item of a large document', () => { + assert.deepStrictEqual(expandSmart(array, undefined, [], 10), { + expanded: true, + items: [{ expanded: true, properties: {}, type: 'object' }], + type: 'array', + visibleSections: [{ end: 100, start: 0 }] + }) + }) + + test('should expand only the object root of a large document', () => { + assert.deepStrictEqual(expandSmart(object, undefined, [], 10), { + expanded: true, + properties: {}, + type: 'object' + }) + }) + + test('should expand all nested properties of an object when it is a small document', () => { + assert.deepStrictEqual(expandSmart(object, undefined, [], 1000), { + expanded: true, + properties: { + a: { expanded: true, properties: {}, type: 'object' }, + b: { expanded: true, properties: {}, type: 'object' }, + c: { expanded: true, properties: {}, type: 'object' }, + d: { expanded: true, properties: {}, type: 'object' }, + e: { expanded: true, properties: {}, type: 'object' } + }, + type: 'object' + }) + }) + }) + + describe('collapsePath', () => { + const json = { + largeArray: range(0, 300).map((index) => ({ id: index })) + } + + const idState: DocumentState = { type: 'value', enforceString: true } + const documentState: DocumentState = { + type: 'object', + expanded: true, + properties: { + largeArray: { + type: 'array', + expanded: true, + items: initArray([ + 120, + { + type: 'object', + expanded: true, + properties: { id: idState } + } + ]), + visibleSections: [{ start: 0, end: 200 }] + } + } + } + + test('collapse a path (recursive)', () => { + assert.deepStrictEqual(collapsePath(json, documentState, [], true), { + type: 'object', + expanded: false, + properties: { + largeArray: { + type: 'array', + expanded: false, + items: [], + visibleSections: [{ start: 0, end: 100 }] + } + } + }) + }) + + test('collapse a path (non-recursive)', () => { + assert.deepStrictEqual(collapsePath(json, documentState, [], false), { + type: 'object', + expanded: false, + properties: { + largeArray: { + type: 'array', + expanded: true, + items: initArray([ + 120, + { type: 'object', expanded: true, properties: { id: idState } } + ]), + visibleSections: [{ start: 0, end: 200 }] + } + } + }) + }) + + test('collapse a nested path (recursive)', () => { + assert.deepStrictEqual(collapsePath(json, documentState, ['largeArray'], true), { + type: 'object', + expanded: true, + properties: { + largeArray: { + type: 'array', + expanded: false, + items: [], + visibleSections: [{ start: 0, end: 100 }] + } + } + }) + }) + + test('collapse a nested path (non-recursive)', () => { + assert.deepStrictEqual(collapsePath(json, documentState, ['largeArray'], false), { + type: 'object', + expanded: true, + properties: { + largeArray: { + type: 'array', + expanded: false, + items: initArray([ + 120, + { type: 'object', expanded: true, properties: { id: idState } } + ]), + visibleSections: [{ start: 0, end: 100 }] + } + } + }) + }) + + test('collapse should do nothing on a non-existing path (1)', () => { + const nonExpandedState: DocumentState = { + type: 'object', + expanded: false, + properties: {} + } + + assert.deepStrictEqual(collapsePath(json, nonExpandedState, [], false), { + type: 'object', + expanded: false, + properties: {} + }) + }) + + test('collapse should do nothing on a non-existing path (2)', () => { + const nonExpandedState: DocumentState = { + type: 'object', + expanded: false, + properties: {} + } + + // TODO: it would be more neat if the documentState was left untouched since it is not collapsed anyway + assert.deepStrictEqual(collapsePath(json, nonExpandedState, ['largeArray'], false), { + type: 'object', + expanded: false, + properties: { + largeArray: { + expanded: false, + items: [], + type: 'array', + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + } + }) + }) }) }) @@ -1528,3 +1829,26 @@ function getVisibleIndices(json: unknown, visibleSections: VisibleSection[]): nu return visibleIndices } + +/** + * Helper function to initialize a sparse array with items at specific indices only + * + * Example usage (creating an array with items at index 0, 1, 2, and 5 but not at index 3 and 4: + * + * initArray( + * [0, "item 0"], + * [1, "item 1"], + * [2, "item 2"], + * [5, "item 5"] + * ) + * + */ +function initArray(...entries: Array<[index: number, item: T]>): T[] { + const array: T[] = [] + + entries.forEach(([index, item]) => { + array[index] = item + }) + + return array +} diff --git a/src/lib/logic/documentState.ts b/src/lib/logic/documentState.ts index 36527b08..3f321dee 100644 --- a/src/lib/logic/documentState.ts +++ b/src/lib/logic/documentState.ts @@ -81,10 +81,13 @@ export function createDocumentState({ json, expand }: CreateDocumentStateProps): DocumentState | undefined { - let documentState = createRecursiveState({ json, factory: documentStateFactory }) as DocumentState + let documentState: DocumentState | undefined = createRecursiveState({ + json, + factory: documentStateFactory + }) as DocumentState if (expand && documentState) { - documentState = expandWithCallback(json, documentState, [], expand) + documentState = expandPath(json, documentState, [], expand) } return documentState @@ -151,72 +154,100 @@ export function ensureRecursiveState( export function syncDocumentState( json: unknown, - documentState: DocumentState | undefined + documentState: DocumentState | undefined, + path: JSONPath = [] ): DocumentState | undefined { - if (json === undefined || documentState === undefined) { - return undefined - } + return _transformDocumentState( + json, + documentState, + path, + (nestedJson, nestedState) => { + if (nestedJson === undefined || nestedState === undefined) { + return undefined + } - if (Array.isArray(json)) { - if (!isArrayRecursiveState(documentState)) { - const expanded = isExpandableState(documentState) ? documentState.expanded : false - return createArrayDocumentState({ expanded }) - } + if (Array.isArray(nestedJson)) { + if (isArrayRecursiveState(nestedState)) { + return nestedState + } - const items: DocumentState[] = [] - for (let i = 0; i < Math.min(documentState.items.length, json.length); i++) { - const itemState = syncDocumentState(json[i], documentState.items[i]) - if (itemState !== undefined) { - items[i] = itemState + const expanded = isExpandableState(nestedState) ? nestedState.expanded : false + return createArrayDocumentState({ expanded }) } - } - const changed = !strictShallowEqual(items, documentState.items) + if (isObject(nestedJson)) { + if (isObjectRecursiveState(nestedState)) { + return nestedState + } - return changed ? { ...documentState, items } : documentState - } + const expanded = isExpandableState(nestedState) ? nestedState.expanded : false + return createObjectDocumentState({ expanded }) + } - if (isObject(json)) { - if (!isObjectRecursiveState(documentState)) { - const expanded = isExpandableState(documentState) ? documentState.expanded : false - return createObjectDocumentState({ expanded }) - } + // json is of type value + if (isValueRecursiveState(nestedState)) { + return nestedState + } + + // type of state does not match the actual type of the json + return undefined + }, + () => true + ) +} +function _transformDocumentState( + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath, + callback: ( + nestedJson: unknown, + nestedState: DocumentState | undefined, + path: JSONPath + ) => DocumentState | undefined, + recurse: (nestedState: DocumentState | undefined) => boolean +): DocumentState | undefined { + const updatedState = callback(json, documentState, path) + + if (Array.isArray(json) && isArrayRecursiveState(updatedState) && recurse(updatedState)) { + const items: (DocumentState | undefined)[] = [] + + forEachVisibleIndex(json, updatedState.visibleSections, (index) => { + const itemPath = path.concat(String(index)) + const value = json[index] + const item = updatedState.items[index] + const updatedItem = _transformDocumentState(value, item, itemPath, callback, recurse) + if (updatedItem !== undefined) { + items[index] = updatedItem + } + }) + + const changed = !strictShallowEqual(items, updatedState.items) + + return changed ? { ...updatedState, items } : updatedState + } + + if (isObject(json) && isObjectRecursiveState(updatedState) && recurse(updatedState)) { const properties: ObjectDocumentState['properties'] = {} - Object.keys(documentState.properties).forEach((key) => { + Object.keys(json).forEach((key) => { + const propPath = path.concat(key) const value = json[key] - if (value !== undefined) { - const propertyState = syncDocumentState(value, documentState.properties[key]) - if (propertyState !== undefined) { - properties[key] = propertyState - } + const prop = updatedState.properties[key] + const updatedProp = _transformDocumentState(value, prop, propPath, callback, recurse) + if (updatedProp !== undefined) { + properties[key] = updatedProp } }) const changed = !strictShallowEqual( Object.values(properties), - Object.values(documentState.properties) + Object.values(updatedState.properties) ) - return changed ? { ...documentState, properties } : documentState - } - - // json is of type value - if (isValueRecursiveState(documentState)) { - return documentState + return changed ? { ...updatedState, properties } : updatedState } - return undefined -} - -export function getVisibleSections( - json: unknown, - documentState: DocumentState | undefined, - path: JSONPath -): VisibleSection[] { - const valueState = getInRecursiveState(json, documentState, path) - - return isArrayRecursiveState(valueState) ? valueState.visibleSections : DEFAULT_VISIBLE_SECTIONS + return updatedState } /** @@ -232,50 +263,19 @@ export function forEachVisibleIndex( }) } -/** - * Expand all nodes on given path - * The end of the path itself is not expanded - */ -export function expandPath( - json: unknown, - documentState: DocumentState | undefined, - path: JSONPath -): DocumentState | undefined { - let updatedState = documentState - - for (let i = 0; i < path.length; i++) { - const partialPath = path.slice(0, i) - updatedState = expandSingleItem(json, updatedState, partialPath) - - if (i < path.length) { - updatedState = updateInDocumentState( - json, - updatedState, - partialPath, - (_value, nestedState) => { - if (!isArrayRecursiveState(nestedState)) { - return nestedState - } - - const index = int(path[i]) - if (inVisibleSection(nestedState.visibleSections, index)) { - return nestedState - } +export function expandVisibleSection(state: ArrayDocumentState, index: number): ArrayDocumentState { + if (inVisibleSection(state.visibleSections, index)) { + return state + } - const start = currentRoundNumber(index) - const end = nextRoundNumber(start) - const newVisibleSection = { start, end } + const start = currentRoundNumber(index) + const end = nextRoundNumber(start) + const newVisibleSection = { start, end } - return { - ...nestedState, - visibleSections: mergeSections(nestedState.visibleSections.concat(newVisibleSection)) - } - } - ) - } + return { + ...state, + visibleSections: mergeSections(state.visibleSections.concat(newVisibleSection)) } - - return updatedState } export function toRecursiveStatePath(json: unknown, path: JSONPath): JSONPath { @@ -303,122 +303,111 @@ export function toRecursiveStatePath(json: unknown, path: JSONPath): JSONPath { } /** - * Expand a node, end expand its children according to the provided callback - * Nodes that are already expanded will be left untouched + * Expand all nodes along the given path, and expand invisible array sections if needed. + * Then, optionally expand child nodes according to the provided callback. */ -export function expandWithCallback( +export function expandPath( json: unknown | undefined, documentState: DocumentState | undefined, path: JSONPath, - expandedCallback: OnExpand -): DocumentState { - // FIXME: updateInDocumentState and ensureNestedDocumentState here - let updatedState = ensureRecursiveState(json, documentState, path, documentStateFactory) - - // FIXME: simplify this function - - function recurse(value: unknown) { - const pathIndex = currentPath.length - const pathStateIndex = currentStatePath.length + 1 - - if (Array.isArray(value)) { - if (expandedCallback(currentPath)) { - updatedState = updateIn( - updatedState, - currentStatePath, - (value: DocumentState | undefined) => { - return value - ? { ...value, expanded: true } - : createArrayDocumentState({ expanded: true }) - } - ) - - if (value.length > 0) { - currentStatePath.push('items') - - const visibleSections = getVisibleSections(json, documentState, path) + callback: OnExpand +): DocumentState | undefined { + let updatedState = documentState - forEachVisibleIndex(value, visibleSections, (index) => { - const indexStr = String(index) - currentPath[pathIndex] = indexStr - currentStatePath[pathStateIndex] = indexStr + // Step 1: expand all nodes along the path, and update visibleSections if needed + for (let i = 0; i < path.length; i++) { + const partialPath = path.slice(0, i) - recurse(value[index]) - }) + updatedState = updateInDocumentState(json, updatedState, partialPath, (_, nestedState) => { + const updatedState = + isExpandableState(nestedState) && !nestedState.expanded + ? { ...nestedState, expanded: true } + : nestedState - currentPath.pop() - currentPath.pop() - currentStatePath.pop() - currentStatePath.pop() - } + if (isArrayRecursiveState(updatedState)) { + const index = int(path[i]) + return expandVisibleSection(updatedState, index) } - } else if (isObject(value)) { - if (expandedCallback(currentPath)) { - updatedState = updateIn( - updatedState, - currentStatePath, - (value: DocumentState | undefined) => { - return value - ? { ...value, expanded: true } - : createObjectDocumentState({ expanded: true }) - } - ) - - const keys = Object.keys(value) - if (keys.length > 0) { - currentStatePath.push('properties') - for (const key of keys) { - currentPath[pathIndex] = key - currentStatePath[pathStateIndex] = key + return updatedState + }) + } - recurse(value[key]) - } + // Step 2: recursively expand child nodes tested with the callback + return updateInDocumentState(json, updatedState, path, (nestedValue, nestedState) => { + const relativePath: JSONPath = [] + return _expandRecursively(nestedValue, nestedState, relativePath, callback) + }) +} - currentPath.pop() - currentPath.pop() - currentStatePath.pop() - currentStatePath.pop() - } +function _expandRecursively( + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath, + callback: OnExpand +): DocumentState | undefined { + return _transformDocumentState( + json, + documentState, + path, + (nestedJson, nestedState, nestedPath) => { + if (Array.isArray(nestedJson) && callback(nestedPath)) { + return isArrayRecursiveState(nestedState) + ? nestedState.expanded + ? nestedState + : { ...nestedState, expanded: true } + : createArrayDocumentState({ expanded: true }) } - } - } - const currentPath = path.slice() - const currentStatePath = toRecursiveStatePath(json, currentPath) - const value = json !== undefined ? getIn(json, path) : json - if (value !== undefined) { - recurse(value) - } + if (isObject(nestedJson) && callback(nestedPath)) { + return isObjectRecursiveState(nestedState) + ? nestedState.expanded + ? nestedState + : { ...nestedState, expanded: true } + : createObjectDocumentState({ expanded: true }) + } - return updatedState + return nestedState + }, + (nestedState) => isExpandableState(nestedState) && nestedState.expanded + ) } -// TODO: write unit tests -export function expandSingleItem( +export function collapsePath( json: unknown, documentState: DocumentState | undefined, - path: JSONPath + path: JSONPath, + recursive: boolean ): DocumentState | undefined { - return updateInDocumentState(json, documentState, path, (_value, state) => { - return isExpandableState(state) ? { ...state, expanded: true } : state + return updateInDocumentState(json, documentState, path, (nestedJson, nestedState) => { + return recursive ? _collapseRecursively(nestedJson, nestedState, path) : _collapse(nestedState) }) } -// TODO: write unit tests -export function collapsePath( +function _collapse(documentState: T): T { + if (isArrayRecursiveState(documentState) && documentState.expanded) { + return { ...documentState, expanded: false, visibleSections: DEFAULT_VISIBLE_SECTIONS } + } + + if (isObjectRecursiveState(documentState) && documentState.expanded) { + return { ...documentState, expanded: false } + } + + return documentState +} + +function _collapseRecursively( json: unknown, documentState: DocumentState | undefined, path: JSONPath ): DocumentState | undefined { - return updateInDocumentState(json, documentState, path, (_value, state) => { - // clear the state of nested objects/arrays - return isObjectRecursiveState(state) - ? createObjectDocumentState({ expanded: false }) - : isArrayRecursiveState(state) - ? createArrayDocumentState({ expanded: false }) - : state - }) + return _transformDocumentState( + json, + documentState, + path, + (_, nestedState) => _collapse(nestedState), + () => true + ) } /** @@ -517,7 +506,7 @@ export function updateInRecursiveState( json: unknown, documentState: T | undefined, path: JSONPath, - transform: (value: unknown, state: T) => T, + transform: (value: unknown, state: T) => T | undefined, factory: RecursiveStateFactory ): T { const ensuredState: T = ensureRecursiveState(json, documentState, path, factory) @@ -527,26 +516,26 @@ export function updateInRecursiveState( }) } -export function setInDocumentState( - json: unknown, - documentState: DocumentState | undefined, +export function setInDocumentState( + json: unknown | undefined, + documentState: T | undefined, path: JSONPath, value: unknown -): DocumentState | undefined { +): T | undefined { return setInRecursiveState(json, documentState, path, value, documentStateFactory) } export function updateInDocumentState( - json: unknown, + json: unknown | undefined, documentState: T | undefined, path: JSONPath, - transform: (value: unknown, state: T) => T + transform: (value: unknown, state: T) => T | undefined ): T { return updateInRecursiveState(json, documentState, path, transform, documentStateFactory) } export function deleteInDocumentState( - json: unknown, + json: unknown | undefined, documentState: T | undefined, path: JSONPath ): T | undefined { @@ -579,8 +568,7 @@ export function documentStateAdd( ...arrayState, items: index < items.length - ? // eslint-disable-next-line no-sparse-arrays - insertItemsAt(items, index, stateValue !== undefined ? [stateValue] : [,]) + ? insertItemsAt(items, index, stateValue !== undefined ? [stateValue] : Array(1)) : items, visibleSections: shiftVisibleSections(visibleSections, index, 1) } @@ -857,34 +845,37 @@ export function getNextVisiblePath( * Expand recursively when the expanded contents is small enough, * else expand in a minimalistic way */ -// TODO: write unit test -export function expandRecursive( - json: unknown, +export function expandSmart( + json: unknown | undefined, documentState: DocumentState | undefined, - path: JSONPath + path: JSONPath, + maxSize: number = MAX_DOCUMENT_SIZE_EXPAND_ALL ): DocumentState | undefined { - const expandContents: unknown | undefined = getIn(json, path) - if (expandContents === undefined) { - return documentState - } + const nestedJson = getIn(json, path) + const callback = isLargeContent({ json: nestedJson }, maxSize) ? expandMinimal : expandAll - const expandAllRecursive = !isLargeContent({ json: expandContents }, MAX_DOCUMENT_SIZE_EXPAND_ALL) - const expandCallback = expandAllRecursive ? expandAll : expandMinimal + return expandPath(json, documentState, path, callback) +} - return expandWithCallback(json, documentState, path, expandCallback) +/** + * Expand the root array or object, and in case of an array, expand the first array item + */ +export function expandMinimal(relativePath: JSONPath): boolean { + // first item of an array + return relativePath.length === 0 ? true : relativePath.length === 1 && relativePath[0] === '0' } -// TODO: write unit test -export function expandMinimal(path: JSONPath): boolean { - return path.length === 0 ? true : path.length === 1 && path[0] === '0' // first item of an array +/** + * Expand the root array or object + */ +export function expandSelf(relativePath: JSONPath): boolean { + return relativePath.length === 0 } -// TODO: write unit test export function expandAll(): boolean { return true } -// TODO: write unit test -export function getDefaultExpand(json: unknown): OnExpand { - return isLargeContent({ json }, MAX_DOCUMENT_SIZE_EXPAND_ALL) ? expandMinimal : expandAll +export function expandNone(): boolean { + return false } diff --git a/src/lib/logic/selection.test.ts b/src/lib/logic/selection.test.ts index c80ede34..2b38e5ad 100644 --- a/src/lib/logic/selection.test.ts +++ b/src/lib/logic/selection.test.ts @@ -16,6 +16,7 @@ import { getSelectionRight, getSelectionUp, pathInSelection, + pathStartsWith, selectionIfOverlapping, selectionToPartialJson } from './selection.js' @@ -886,4 +887,17 @@ describe('selection', () => { assert.strictEqual(pathInSelection(json, selection, ['obj', 'arr', '1']), true) }) }) + + describe('pathStartsWith', () => { + test('should determine whether a path starts with parent path', () => { + assert.strictEqual(pathStartsWith([], []), true) + assert.strictEqual(pathStartsWith(['a'], []), true) + assert.strictEqual(pathStartsWith(['a'], ['a']), true) + assert.strictEqual(pathStartsWith(['a', 'b'], ['a']), true) + assert.strictEqual(pathStartsWith(['a', 'b'], ['a', 'b']), true) + assert.strictEqual(pathStartsWith(['a'], ['a', 'b']), false) + assert.strictEqual(pathStartsWith(['a', 'b'], ['b']), false) + assert.strictEqual(pathStartsWith(['a', 'b'], ['a', 'b', 'c']), false) + }) + }) }) diff --git a/src/lib/logic/selection.ts b/src/lib/logic/selection.ts index b038b91f..04407ffa 100644 --- a/src/lib/logic/selection.ts +++ b/src/lib/logic/selection.ts @@ -294,7 +294,7 @@ export function getSelectionDown( // if the focusPath is an Array or object, we must not step into it but // over it, we pass state with this array/object collapsed const collapsedState = isObjectOrArray(getIn(json, focusPath)) - ? collapsePath(json, documentState, focusPath) + ? collapsePath(json, documentState, focusPath, true) : documentState const nextPath = getNextVisiblePath(json, documentState, focusPath) @@ -593,7 +593,6 @@ export function findRootPath(json: unknown, selection: JSONSelection): JSONPath : initial(getFocusPath(selection)) // the parent path of the paths } -// TODO: unit test export function pathStartsWith(path: JSONPath, parentPath: JSONPath): boolean { if (path.length < parentPath.length) { return false diff --git a/src/lib/types.ts b/src/lib/types.ts index 51f3b012..c8c34caa 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -384,7 +384,7 @@ export type OnSort = (params: { export type OnFind = (findAndReplace: boolean) => void export type OnPaste = (pastedText: string) => void export type OnPasteJson = (pastedJson: { path: JSONPath; contents: unknown }) => void -export type OnExpand = (path: JSONPath) => boolean +export type OnExpand = (relativePath: JSONPath) => boolean export type OnRenderValue = (props: RenderValueProps) => RenderValueComponentDescription[] export type OnClassName = (path: JSONPath, value: unknown) => string | undefined export type OnChangeMode = (mode: Mode) => void