From 3a653925f5e441864782b71fc1113055b3a720f6 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Tue, 24 Sep 2024 11:59:46 +0200 Subject: [PATCH] feat: improve the expand and selection API's and the internal state, and add a new query language JSON Query BREAKING CHANGE: - The internal state is refactored. This should not give any issues except when relying on some internal or undocumented features. - Changed the API to consistently use `undefined` instead of `null`. This involves properties `selection`, `onChange` (properties `contentErrors and `patchResult`), `onRenderContextMenu` (property `selection`), `onSelect`, and methods `validate`, and `select`. - Old deprecation messages are removed. - 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. - The property `edit` is removed from the types `KeySelection` and `ValueSelection`, and two new types `EditKeySelection` and `EditValueSelection` are added. - The helper functions `createKeySelection` and `createValueSelection` are changed, argument `edit` is removed, and two new helper functions `createEditKeySelection` and `createEditValueSelection` are added. - The API of the component `EditableValue` requires an additional property `selection`. - Some of the class names related to selection highlighting are moved/changed. - The default query language is changed to `jsonquery`. - The vanilla editor needs to be instantiated using `createJSONEditor(...)` instead of `new JSONEditor(...)` in preparation for the upgrade to Svelte 5. --- .browserslistrc | 4 + .eslintrc.json | 31 - README-VANILLA.md | 26 +- README.md | 140 +- eslint.config.js | 63 + examples/browser/basic_usage.html | 4 +- examples/browser/custom_theme_color.html | 4 +- examples/browser/custom_value_renderer.html | 4 +- examples/browser/json_schema_validation.html | 4 +- examples/browser/toggle_options.html | 4 +- package-lock.json | 5613 ++++++++++------- package.json | 103 +- rollup.config.vanilla-library.js | 14 +- src/lib/actions/onEscape.ts | 42 +- src/lib/assets/svelte-simple-modal/types.d.ts | 268 - src/lib/components/JSONEditor.scss | 5 +- src/lib/components/JSONEditor.svelte | 315 +- .../__snapshots__/JSONEditor.test.ts.snap | 645 +- .../components/controls/DropdownButton.scss | 3 +- src/lib/components/controls/EditableDiv.scss | 8 +- .../components/controls/EditableDiv.svelte | 18 +- src/lib/components/controls/SearchBox.scss | 5 +- src/lib/components/controls/SearchBox.svelte | 32 +- .../controls/ValidationErrorsOverview.svelte | 1 + .../contextmenu/ContextMenuPointer.scss | 19 +- .../contextmenu/ContextMenuPointer.svelte | 6 +- .../components/controls/createFocusTracker.ts | 2 +- .../navigationBar/NavigationBar.svelte | 2 +- src/lib/components/modals/CopyPasteModal.scss | 25 +- .../components/modals/CopyPasteModal.svelte | 14 +- src/lib/components/modals/Header.svelte | 16 +- .../components/modals/JSONEditorModal.scss | 12 +- .../components/modals/JSONEditorModal.svelte | 230 +- .../components/modals/JSONRepairModal.scss | 5 - .../components/modals/JSONRepairModal.svelte | 17 +- src/lib/components/modals/Modal.scss | 101 - src/lib/components/modals/Modal.svelte | 127 + src/lib/components/modals/ModalRef.svelte | 21 - src/lib/components/modals/SortModal.scss | 76 +- src/lib/components/modals/SortModal.svelte | 31 +- src/lib/components/modals/TransformModal.scss | 229 +- .../components/modals/TransformModal.svelte | 304 +- .../modals/TransformModalHeader.svelte | 7 +- .../components/modals/TransformWizard.svelte | 20 +- .../modals/popup/AbsolutePopupEntry.svelte | 2 + .../modals/repair/JSONRepairComponent.svelte | 10 +- .../components/modes/JSONEditorRoot.svelte | 25 +- .../modes/tablemode/ColumnHeader.svelte | 2 +- .../modes/tablemode/JSONValue.svelte | 67 - .../components/modes/tablemode/TableMode.scss | 23 +- .../modes/tablemode/TableMode.svelte | 758 +-- .../modes/tablemode/TableModeWelcome.scss | 4 +- .../createTableContextMenuItems.ts | 24 +- .../modes/tablemode/menu/TableMenu.svelte | 2 +- .../modes/tablemode/tag/InlineValue.scss | 10 +- .../components/modes/textmode/StatusBar.scss | 6 +- .../components/modes/textmode/TextMode.svelte | 35 +- .../modes/treemode/CollapsedItems.scss | 5 + .../modes/treemode/CollapsedItems.svelte | 2 +- .../components/modes/treemode/JSONKey.scss | 9 - .../components/modes/treemode/JSONKey.svelte | 32 +- .../components/modes/treemode/JSONNode.scss | 267 +- .../components/modes/treemode/JSONNode.svelte | 290 +- .../modes/treemode/JSONValue.svelte | 3 +- .../components/modes/treemode/TreeMode.scss | 8 + .../components/modes/treemode/TreeMode.svelte | 817 +-- .../contextmenu/createTreeContextMenuItems.ts | 57 +- .../modes/treemode/menu/TreeMenu.svelte | 4 +- .../components/modes/treemode/singleton.ts | 12 +- src/lib/constants.ts | 23 - src/lib/index-vanilla.ts | 36 + src/lib/index.ts | 36 +- src/lib/logic/actions.ts | 305 +- src/lib/logic/documentState.test.ts | 1615 +++-- src/lib/logic/documentState.ts | 1046 +-- src/lib/logic/dragging.test.ts | 17 +- src/lib/logic/dragging.ts | 16 +- src/lib/logic/operations.test.ts | 40 +- src/lib/logic/operations.ts | 10 +- src/lib/logic/search.test.ts | 169 +- src/lib/logic/search.ts | 137 +- src/lib/logic/selection.test.ts | 425 +- src/lib/logic/selection.ts | 296 +- src/lib/logic/table.test.ts | 36 +- src/lib/logic/table.ts | 37 +- src/lib/logic/validation.test.ts | 85 +- src/lib/logic/validation.ts | 74 +- .../plugins/query/javascriptQueryLanguage.ts | 8 +- .../plugins/query/jsonQueryLanguage.test.ts | 219 + src/lib/plugins/query/jsonQueryLanguage.ts | 65 + .../query/jsonpathQueryLanguage.test.ts | 169 + .../plugins/query/jsonpathQueryLanguage.ts | 64 + src/lib/plugins/query/lodashQueryLanguage.ts | 8 +- .../validator/createAjvValidator.test.ts | 11 - .../plugins/validator/createAjvValidator.ts | 25 +- .../plugins/value/components/ColorPicker.scss | 2 +- .../value/components/EditableValue.svelte | 64 +- .../plugins/value/components/EnumValue.scss | 6 - .../plugins/value/components/EnumValue.svelte | 9 +- .../value/components/ReadonlyValue.scss | 7 +- .../value/components/ReadonlyValue.svelte | 20 +- .../value/components/utils/getValueClass.ts | 7 +- src/lib/plugins/value/renderJSONSchemaEnum.ts | 4 +- src/lib/plugins/value/renderValue.ts | 10 +- src/lib/styles.scss | 25 + src/lib/themes/defaults.scss | 17 +- src/lib/typeguards.ts | 39 +- src/lib/types.ts | 286 +- src/lib/utils/arrayUtils.ts | 4 + src/lib/utils/copyToClipboard.ts | 6 +- src/lib/utils/debug.ts | 2 + src/lib/utils/domUtils.ts | 55 +- src/lib/utils/jsonPointer.test.ts | 21 - src/lib/utils/jsonPointer.ts | 31 - src/lib/utils/jsonSchemaUtils.test.ts | 8 +- src/lib/utils/jsonSchemaUtils.ts | 21 +- src/lib/utils/jsonUtils.test.ts | 4 +- src/lib/utils/jsonUtils.ts | 26 +- src/lib/utils/navigatorUtils.ts | 7 +- src/lib/utils/numberUtils.ts | 2 +- src/lib/utils/objectUtils.test.ts | 2 + src/lib/utils/pathUtils.test.ts | 19 +- src/lib/utils/pathUtils.ts | 10 +- src/lib/utils/typeUtils.test.ts | 24 +- src/lib/utils/typeUtils.ts | 18 +- .../components/EditableValueInput.svelte | 2 +- src/routes/components/EvaluatorAction.ts | 9 +- src/routes/components/ReadonlyPassword.svelte | 17 +- src/routes/development/+page.svelte | 36 +- .../custom_dynamic_styling/+page.svelte | 2 + svelte.config.js | 2 +- tools/createVanillaPackageJson.js | 13 +- tools/develop-vanilla.html | 9 +- tools/test-shadow-dom.html | 4 +- 134 files changed, 9392 insertions(+), 7524 deletions(-) create mode 100644 .browserslistrc delete mode 100644 .eslintrc.json create mode 100644 eslint.config.js delete mode 100644 src/lib/assets/svelte-simple-modal/types.d.ts delete mode 100644 src/lib/components/modals/JSONRepairModal.scss delete mode 100644 src/lib/components/modals/Modal.scss create mode 100644 src/lib/components/modals/Modal.svelte delete mode 100644 src/lib/components/modals/ModalRef.svelte delete mode 100644 src/lib/components/modes/tablemode/JSONValue.svelte create mode 100644 src/lib/index-vanilla.ts create mode 100644 src/lib/plugins/query/jsonQueryLanguage.test.ts create mode 100644 src/lib/plugins/query/jsonQueryLanguage.ts create mode 100644 src/lib/plugins/query/jsonpathQueryLanguage.test.ts create mode 100644 src/lib/plugins/query/jsonpathQueryLanguage.ts delete mode 100644 src/lib/utils/jsonPointer.test.ts delete mode 100644 src/lib/utils/jsonPointer.ts diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 00000000..2649f7a4 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,4 @@ +# Test out at: https://browsersl.ist/ + +fully supports es6 +not dead diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 8de7a900..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "root": true, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:svelte/recommended", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "extraFileExtensions": [".svelte"], - "sourceType": "module", - "ecmaVersion": 2019 - }, - "overrides": [ - { - "files": ["*.svelte"], - "parser": "svelte-eslint-parser", - "parserOptions": { - "parser": "@typescript-eslint/parser" - } - } - ], - "env": { - "browser": true, - "es2017": true, - "es2020": true, - "node": true, - "mocha": true - } -} diff --git a/README-VANILLA.md b/README-VANILLA.md index 424d5f71..83e9cd48 100644 --- a/README-VANILLA.md +++ b/README-VANILLA.md @@ -2,7 +2,7 @@ A web-based tool to view, edit, format, transform, and validate JSON. -Try it out: https://jsoneditoronline.org +Try it out: This is the vanilla variant of `svelte-jsoneditor`, which can be used in vanilla JavaScript or frameworks like SolidJS, React, Vue, Angular. @@ -38,19 +38,19 @@ If you have a setup for your project with a bundler (like Vite, Rollup, or Webpa ```ts // for use in a React, Vue, or Angular project -import { JSONEditor } from 'vanilla-jsoneditor' +import { createJSONEditor } from 'vanilla-jsoneditor' ``` If you want to use the library straight in the browser, use the provided standalone ES bundle: ```ts // for use directly in the browser -import { JSONEditor } from 'vanilla-jsoneditor/standalone.js' +import { createJSONEditor } from 'vanilla-jsoneditor/standalone.js' ``` -The standalone bundle contains all dependencies of `vanilla-jsoneditor`, for example `lodash-es` and `Ajv`. If you use some of these dependencies in your project too, it means that they will be bundled twice in your web application, leading to a needlessly large application size. In general, it is preferable to use the default `import { JSONEditor } from 'vanilla-jsoneditor'` so dependencies can be reused. +The standalone bundle contains all dependencies of `vanilla-jsoneditor`, for example `lodash-es` and `Ajv`. If you use some of these dependencies in your project too, it means that they will be bundled twice in your web application, leading to a needlessly large application size. In general, it is preferable to use the default `import { createJSONEditor } from 'vanilla-jsoneditor'` so dependencies can be reused. -## Use (Browser example loading the ES module): +## Use (Browser example loading the ES module) ```html @@ -62,11 +62,11 @@ The standalone bundle contains all dependencies of `vanilla-jsoneditor`, for exa
- - - -
- {#key instanceId} - - {/key} -
-
-
+
+ {#key instanceId} + + {/key} +
+ + {#if sortModalProps} + { + sortModalProps?.onClose() + sortModalProps = undefined + }} + /> + {/if} + + {#if transformModalProps} + { + transformModalProps?.onClose() + transformModalProps = undefined + }} + /> + {/if} + + {#if jsonEditorModalProps} + { + jsonEditorModalProps?.onClose() + jsonEditorModalProps = undefined + }} + /> + {/if}
diff --git a/src/lib/components/__snapshots__/JSONEditor.test.ts.snap b/src/lib/components/__snapshots__/JSONEditor.test.ts.snap index cd46531e..2ce22bb8 100644 --- a/src/lib/components/__snapshots__/JSONEditor.test.ts.snap +++ b/src/lib/components/__snapshots__/JSONEditor.test.ts.snap @@ -3,22 +3,19 @@ exports[`JSONEditor > render table mode 1`] = `
- - -
@@ -442,7 +463,7 @@ exports[`JSONEditor > render table mode 1`] = ` class="jse-table-invisible-end-section" > { // trigger on the next tick to prevent the editor not getting focus setTimeout(() => selectError(validationError)) diff --git a/src/lib/components/controls/contextmenu/ContextMenuPointer.scss b/src/lib/components/controls/contextmenu/ContextMenuPointer.scss index d15d5553..ac26419a 100644 --- a/src/lib/components/controls/contextmenu/ContextMenuPointer.scss +++ b/src/lib/components/controls/contextmenu/ContextMenuPointer.scss @@ -13,12 +13,29 @@ background: transparent; border-radius: 2px; - background: $context-menu-pointer-background; + background: $context-menu-pointer-hover-background; color: $context-menu-pointer-color; border: none; box-shadow: $controls-box-shadow; + &.jse-root { + top: 0; + right: calc(-2px - $context-menu-pointer-size); + } + + &.jse-insert { + right: -1px; + } + &:hover { background: $context-menu-pointer-background-highlight; } + + &.jse-selected { + background: $context-menu-pointer-background; + + &:hover { + background: $context-menu-pointer-background-highlight; + } + } } diff --git a/src/lib/components/controls/contextmenu/ContextMenuPointer.svelte b/src/lib/components/controls/contextmenu/ContextMenuPointer.svelte index 8f59ab3f..6739f962 100644 --- a/src/lib/components/controls/contextmenu/ContextMenuPointer.svelte +++ b/src/lib/components/controls/contextmenu/ContextMenuPointer.svelte @@ -10,11 +10,13 @@ } from '$lib/constants.js' import type { OnContextMenu } from '$lib/types' + export let root: boolean = false + export let insert: boolean = false export let selected: boolean export let onContextMenu: OnContextMenu function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { - let buttonElem: Element | null = event.target as HTMLButtonElement + let buttonElem: Element | undefined = event.target as HTMLButtonElement while (buttonElem && buttonElem.nodeName !== 'BUTTON') { buttonElem = buttonElem.parentNode as Element } @@ -37,6 +39,8 @@ + - + diff --git a/src/lib/components/modals/Header.svelte b/src/lib/components/modals/Header.svelte index 010a8ba4..0045e1ca 100644 --- a/src/lib/components/modals/Header.svelte +++ b/src/lib/components/modals/Header.svelte @@ -1,21 +1,17 @@
@@ -33,17 +29,7 @@ {/if} -
diff --git a/src/lib/components/modals/JSONEditorModal.scss b/src/lib/components/modals/JSONEditorModal.scss index 53a21310..5c4e8b24 100644 --- a/src/lib/components/modals/JSONEditorModal.scss +++ b/src/lib/components/modals/JSONEditorModal.scss @@ -1,9 +1,14 @@ @import '../../styles'; -@import 'Modal'; -.jse-modal.jse-jsoneditor-modal { +.jse-modal-wrapper { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + .jse-modal-contents { - padding-top: 0; + @include jse-modal-contents; .jse-label { font-weight: bold; @@ -27,6 +32,7 @@ flex: 1; min-height: 150px; min-width: 0; + max-width: 100%; display: flex; --jse-theme-color: #{$modal-editor-theme-color}; diff --git a/src/lib/components/modals/JSONEditorModal.svelte b/src/lib/components/modals/JSONEditorModal.svelte index 102bc0d5..e3373810 100644 --- a/src/lib/components/modals/JSONEditorModal.svelte +++ b/src/lib/components/modals/JSONEditorModal.svelte @@ -1,7 +1,7 @@ -
-
- -
-
-
Path
-
- - -
-
Contents
-
- -
- +
+ +
-
-
- {#if error} -
- {error} +
+
+
Path
+
+ + +
+
Contents
+
+ +
+ +
+ +
+ {#if error} +
+ {error} +
+ {/if} + + {#if stack.length > 1} + + {/if} + {#if !readOnly} + + {:else} + + {/if}
- {/if} - - {#if stack.length > 1} - - {/if} - {#if !readOnly} - - {:else} - - {/if} -
+
+
-
+ diff --git a/src/lib/components/modals/JSONRepairModal.scss b/src/lib/components/modals/JSONRepairModal.scss deleted file mode 100644 index 948a1c68..00000000 --- a/src/lib/components/modals/JSONRepairModal.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import '../../styles'; - -.jse-modal.jse-repair { - @include jse-modal-style; -} diff --git a/src/lib/components/modals/JSONRepairModal.svelte b/src/lib/components/modals/JSONRepairModal.svelte index 147acb0d..08abd087 100644 --- a/src/lib/components/modals/JSONRepairModal.svelte +++ b/src/lib/components/modals/JSONRepairModal.svelte @@ -1,29 +1,26 @@ -
+ -
- - + diff --git a/src/lib/components/modals/Modal.scss b/src/lib/components/modals/Modal.scss deleted file mode 100644 index 8fd1a213..00000000 --- a/src/lib/components/modals/Modal.scss +++ /dev/null @@ -1,101 +0,0 @@ -@import '../../styles'; - -.jse-modal { - // styling for the select box, svelte-select - // see docs: https://github.com/rob-balfre/svelte-select#css-custom-properties-variables - :global(.svelte-select) { - --border: #{$svelte-select-border}; - --item-is-active-bg: #{$svelte-select-item-is-active-bg}; - --border-radius: #{$svelte-select-border-radius}; - --background: #{$svelte-select-background}; - --padding: #{$svelte-select-padding}; - --multi-select-padding: #{$svelte-select-multi-select-padding}; - --font-size: #{$svelte-select-font-size}; - --height: 36px; - --multi-item-height: 28px; - --multi-item-margin: 2px; - --multi-item-padding: 2px 8px; - --multi-item-border-radius: 6px; - --indicator-top: 8px; - } - - @include jse-modal-style; - - .jse-modal-contents { - flex: 1; - display: flex; - flex-direction: column; - padding: 20px; - overflow: auto; - min-width: 0; - min-height: 0; - - .jse-actions { - display: flex; - flex-direction: row; - justify-content: flex-end; - - padding-top: $padding; - - button { - &.jse-primary { - @include jsoneditor-button-primary; - } - } - } - } -} - -// custom styling for the modal. -:global(.bg.jse-modal-bg) { - width: 100%; - height: 100%; - top: 0; - left: 0; - background: $modal-overlay-background; -} - -:global(.bg.jse-modal-bg .jse-modal-window-wrap) { - margin: 0; - overflow: auto; -} - -:global(.bg.jse-modal-bg .jse-modal-window) { - max-width: 90%; - margin: 4rem auto 2rem auto; - border-radius: 2px; -} - -:global(.bg.jse-modal-bg .jse-modal-window.jse-modal-window-sort) { - width: 400px; -} - -:global(.bg.jse-modal-bg .jse-modal-window.jse-modal-window-transform) { - width: 1200px; - height: 1200px; - max-height: 80%; - display: flex; -} - -:global(.bg.jse-modal-bg .jse-modal-window.jse-modal-window-jsoneditor) { - width: 800px; - max-height: 500px; - display: flex; -} - -:global(.bg.jse-modal-bg .jse-modal-window:has(div.fullscreen)) { - margin: $padding; - padding: 0; - width: calc(100vw - 2 * $padding); - height: calc(100vh - 2 * $padding); - max-width: none; - max-height: none; -} - -:global(.bg.jse-modal-bg .jse-modal-container) { - flex: 1; - display: flex; - flex-direction: column; - padding: 0; - max-height: none; -} diff --git a/src/lib/components/modals/Modal.svelte b/src/lib/components/modals/Modal.svelte new file mode 100644 index 00000000..f0b5c7b9 --- /dev/null +++ b/src/lib/components/modals/Modal.svelte @@ -0,0 +1,127 @@ + + + + +
+ +
+
+ + diff --git a/src/lib/components/modals/ModalRef.svelte b/src/lib/components/modals/ModalRef.svelte deleted file mode 100644 index 866302b5..00000000 --- a/src/lib/components/modals/ModalRef.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/src/lib/components/modals/SortModal.scss b/src/lib/components/modals/SortModal.scss index d8174873..3e647918 100644 --- a/src/lib/components/modals/SortModal.scss +++ b/src/lib/components/modals/SortModal.scss @@ -1,50 +1,48 @@ @import '../../styles'; -@import 'Modal'; -.jse-modal.jse-sort { - .jse-modal-contents { - } +.jse-modal-contents { + @include jse-modal-contents; +} - table { - width: 100%; - border-collapse: collapse; - border-spacing: 0; - - th, - td { - text-align: left; - vertical-align: middle; - font-weight: normal; - padding-bottom: $padding; - - input.jse-path { - width: 100%; - box-sizing: border-box; - padding: 6px 16px; // TODO: define variables for those props - border: $input-border; - border-radius: $input-radius; - font-family: inherit; - font-size: inherit; - background: inherit; - color: inherit; - outline: none; - - &:read-only { - background: $input-background-readonly; - } +table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + + th, + td { + text-align: left; + vertical-align: middle; + font-weight: normal; + padding-bottom: $padding; + + input.jse-path { + width: 100%; + box-sizing: border-box; + padding: 6px 16px; // TODO: define variables for those props + border: $input-border; + border-radius: $input-radius; + font-family: inherit; + font-size: inherit; + background: inherit; + color: inherit; + outline: none; + + &:read-only { + background: $input-background-readonly; } + } - :global(.svelte-select input) { - box-sizing: border-box; - } + :global(.svelte-select input) { + box-sizing: border-box; } } +} - .jse-space { - height: 200px; // Trick for the property select box dropdown to be fully visible +.jse-space { + height: 200px; // Trick for the property select box dropdown to be fully visible - .jse-error { - color: $error-color; - } + .jse-error { + color: $error-color; } } diff --git a/src/lib/components/modals/SortModal.svelte b/src/lib/components/modals/SortModal.svelte index 0a24e7d7..5076abd0 100644 --- a/src/lib/components/modals/SortModal.svelte +++ b/src/lib/components/modals/SortModal.svelte @@ -2,19 +2,17 @@ -
-
+ +
0
- 1 +
+ 1 +
+ + + + +
- - - - - - +
+
+ +
+ + + +
- - - - - - +
1
- 2 +
+ 2 +
+ + + + +
- - - - - - +
- Joe +
+ Joe +
+ + + + +
- - - - - - +
2
- 3 +
+ 3 +
+ + + + +
- - - - - - +
+
+ +
+ + + +
- - - - - - +
@@ -456,29 +477,33 @@ exports[`JSONEditor > render table mode 1`] = ` + + + + + + + `; exports[`JSONEditor > render text mode 1`] = `
- - -
render text mode 1`] = `
render text mode 1`] = `
@@ -1022,29 +1046,30 @@ exports[`JSONEditor > render text mode 1`] = `
+ + + +
`; exports[`JSONEditor > render tree mode 1`] = `
- - -
0
:
{
@@ -1531,36 +1557,36 @@ exports[`JSONEditor > render tree mode 1`] = `
@@ -1572,29 +1598,33 @@ exports[`JSONEditor > render tree mode 1`] = `
:
- 1 +
+ 1 +
+ + + +
- - - -
@@ -1604,21 +1634,21 @@ exports[`JSONEditor > render tree mode 1`] = `
@@ -94,7 +93,9 @@ type="text" readonly title="Selected path" - value={!isEmpty(rootPath) ? stringifyJSONPath(rootPath) : '(document root)'} + value={rootPath && !isEmpty(rootPath) + ? stringifyJSONPath(rootPath) + : '(document root)'} /> @@ -140,6 +141,6 @@ - + diff --git a/src/lib/components/modals/TransformModal.scss b/src/lib/components/modals/TransformModal.scss index c1fba5e7..544d49be 100644 --- a/src/lib/components/modals/TransformModal.scss +++ b/src/lib/components/modals/TransformModal.scss @@ -1,158 +1,163 @@ @import '../../styles'; -@import 'Modal'; -.jse-modal.jse-transform { - .jse-modal-contents { - color: inherit; - min-height: 0; - padding: 0; +.jse-transform-modal-inner { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} - .jse-main-contents { - flex: 1; - display: flex; - gap: $padding-double; - min-height: 0; - box-sizing: border-box; +.jse-modal-contents { + color: inherit; - padding: 0 $padding-double $padding; + @include jse-modal-contents($modal-padding: 0); - .jse-query-contents { - flex: 1; - display: flex; - flex-direction: column; + .jse-main-contents { + flex: 1; + display: flex; + gap: $padding-double; + min-height: 0; + box-sizing: border-box; + + padding: 0 $padding-double $padding; - .jse-description { - :global(p) { - margin: $padding 0; + .jse-query-contents { + flex: 1; + display: flex; + flex-direction: column; - &:first-child { - margin-top: 0; - } + .jse-description { + :global(p) { + margin: $padding 0; - &:last-child { - margin-bottom: 0; - } + &:first-child { + margin-top: 0; } - :global(code) { - background: $modal-code-background; - font-family: $font-family-mono; - font-size: $font-size-mono; + &:last-child { + margin-bottom: 0; } } - textarea.jse-query { - flex: 1; - outline: none; - resize: vertical; // prevent resizing horizontally + :global(code) { + background: $modal-code-background; + font-family: $font-family-mono; + font-size: $font-size-mono; } } - .jse-data-contents { + textarea.jse-query { + flex: 1; + outline: none; + resize: vertical; // prevent resizing horizontally + } + } + + .jse-data-contents { + flex: 1; + display: flex; + flex-direction: column; + gap: $padding-double; + + .jse-original-data { flex: 1; display: flex; flex-direction: column; - gap: $padding-double; + min-height: 0; + box-sizing: border-box; - .jse-original-data { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - box-sizing: border-box; - - &.jse-hide { - flex: none; - } + &.jse-hide { + flex: none; } + } - .jse-preview-data { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - box-sizing: border-box; - } + .jse-preview-data { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + box-sizing: border-box; + } - &.jse-hide-original-data { - flex-direction: column; - gap: 0; - margin-bottom: 0; - } + &.jse-hide-original-data { + flex-direction: column; + gap: 0; + margin-bottom: 0; } } + } - .jse-actions { - padding: $padding $padding-double $padding-double; - } + .jse-actions { + padding: $padding $padding-double $padding-double; + } - @media screen and (max-width: 1200px) { - .jse-main-contents { - flex-direction: column; - overflow: auto; + @media screen and (max-width: 1200px) { + .jse-main-contents { + flex-direction: column; + overflow: auto; - .jse-query-contents { - textarea.jse-query { - min-height: 150px; - flex: none; - } + .jse-query-contents { + textarea.jse-query { + min-height: 150px; + flex: none; } + } - .jse-data-contents { - :global(.jse-tree-mode) { - height: 300px; - flex: none; - } + .jse-data-contents { + :global(.jse-tree-mode) { + height: 300px; + flex: none; } } } } +} - .jse-label { - font-weight: bold; - display: block; - box-sizing: border-box; +.jse-label { + font-weight: bold; + display: block; + box-sizing: border-box; - .jse-label-inner { - margin-top: $padding-double; - margin-bottom: $padding-half; - box-sizing: border-box; + .jse-label-inner { + margin-top: $padding-double; + margin-bottom: $padding-half; + box-sizing: border-box; - button { - @include jsoneditor-button; - font-weight: bold; - padding: 0; - } + button { + @include jsoneditor-button; + font-weight: bold; + padding: 0; } } +} - :global(.jse-tree-mode) { - flex: 1; - background: $input-background-readonly; - box-shadow: none; - box-sizing: border-box; +:global(.jse-tree-mode) { + flex: 1; + background: $input-background-readonly; + box-shadow: none; + box-sizing: border-box; - --jse-main-border: #{$input-border}; - } + --jse-main-border: #{$input-border}; +} - input, - textarea { - @include modal-input-mixin; - } +input, +textarea { + @include modal-input-mixin; +} - .jse-preview.jse-error { - flex: 1; - background: $input-background-readonly; - border: $input-border; - color: $error-color; - padding: $padding-half; - } +.jse-preview.jse-error { + flex: 1; + background: $input-background-readonly; + border: $input-border; + color: $error-color; + padding: $padding-half; +} - :global(a) { - color: $a-color; - } +:global(a) { + color: $a-color; +} - :global(a:hover) { - color: $a-color-highlight; - } +:global(a:hover) { + color: $a-color-highlight; } diff --git a/src/lib/components/modals/TransformModal.svelte b/src/lib/components/modals/TransformModal.svelte index ca3bc76f..168dbd22 100644 --- a/src/lib/components/modals/TransformModal.svelte +++ b/src/lib/components/modals/TransformModal.svelte @@ -1,13 +1,12 @@ -
- - -
-
-
-
-
Language
-
-
- - {@html getSelectedQueryLanguage(queryLanguageId).description} -
+ +
+ + +
+
+
+
+
Language
+
+
+ + {@html getSelectedQueryLanguage(queryLanguageId).description} +
-
-
Path
-
- - -
-
- +
+
Path
-
- {#if showWizard} - {#if Array.isArray(selectedJson)} - - {:else} - (Only available for arrays, not for objects) - {/if} - {/if} + -
-
Query
-
- -
-
-
-
- {#if showOriginal} - + {#if showWizard} + {#if Array.isArray(selectedJson)} + + {:else} + (Only available for arrays, not for objects) + {/if} {/if} -
-
+
-
Preview
+
Query
- {#if !previewError} - - {:else} -
- {previewError} + +
+
+
+
+
+ +
- {/if} + {#if showOriginal} + + {/if} +
+
+
+
Preview
+
+ {#if !previewError} + + {:else} +
+ {previewError} +
+ {/if} +
-
-
- +
+ +
-
- -
+ +
+ diff --git a/src/lib/components/modals/TransformModalHeader.svelte b/src/lib/components/modals/TransformModalHeader.svelte index eedc4d3d..2bd0d947 100644 --- a/src/lib/components/modals/TransformModalHeader.svelte +++ b/src/lib/components/modals/TransformModalHeader.svelte @@ -6,18 +6,17 @@ import { faCog } from '@fortawesome/free-solid-svg-icons' import SelectQueryLanguage from '../controls/selectQueryLanguage/SelectQueryLanguage.svelte' import type { AbsolutePopupContext, OnChangeQueryLanguage, QueryLanguage } from '$lib/types.js' - import type { Context } from 'svelte-simple-modal' import Header from './Header.svelte' export let queryLanguages: QueryLanguage[] export let queryLanguageId: string - export let onChangeQueryLanguage: OnChangeQueryLanguage export let fullscreen: boolean + export let onChangeQueryLanguage: OnChangeQueryLanguage + export let onClose: () => void let refConfigButton: HTMLButtonElement | undefined let popupId: number | undefined - const { close } = getContext('simple-modal') const { openAbsolutePopup, closeAbsolutePopup } = getContext('absolute-popup') @@ -40,7 +39,7 @@ } -
+
{/each} {#if showRefreshButton} @@ -1867,7 +1765,10 @@ {/if} - + {#each visibleSection.visibleItems as item, visibleIndex} @@ -1877,9 +1778,9 @@ [String(rowIndex)], validationErrorsByRow?.row )} - {@const searchResultItemsByRow = searchResult?.itemsMap - ? filterPointerOrUndefined(searchResult?.itemsMap, compileJSONPointerProp(rowIndex)) - : undefined} + {@const searchResultByRow = getInRecursiveState(json, searchResults, [ + String(rowIndex) + ])} {#key rowIndex}
- +
- {#if isObjectOrArray(value)} - {@const searchResultItemsByCell = searchResultItemsByRow - ? filterPointerOrUndefined(searchResultItemsByRow, pointer) - : undefined} - {@const containsActiveSearchResult = searchResultItemsByCell - ? Object.values(searchResultItemsByCell).some((items) => - items.some((item) => item.active) - ) - : false} - - {:else} - {@const searchResultItemsByCell = searchResult?.itemsMap - ? filterValueSearchResults(searchResult?.itemsMap, pointer) - : undefined} - - +
+ {#if isObjectOrArray(value)} + {@const searchResultsByCell = flattenSearchResults( + getInRecursiveState(item, searchResultByRow, column) )} - selection={isSelected ? documentState.selection : null} - searchResultItems={searchResultItemsByCell} - {context} - />{/if}{#if !readOnly && isSelected && !isEditingSelection(documentState.selection)} -
- -
- {/if}{#if validationError} + + {@const containsActiveSearchResult = searchResultsByCell + ? searchResultsByCell.some((item) => item.active) + : false} + + {:else} + {@const searchResultItemsByCell = getInRecursiveState( + json, + searchResults, + path + )?.searchResults} + + {/if}{#if !readOnly && isSelected && !isEditingSelection(selection)} +
+ +
+ {/if} +
+ {#if validationError} {/if} @@ -2053,4 +1947,18 @@ {/if} +{#if copyPasteModalOpen} + (copyPasteModalOpen = false)} /> +{/if} + +{#if jsonRepairModalProps} + { + jsonRepairModalProps?.onClose() + jsonRepairModalProps = undefined + }} + /> +{/if} + diff --git a/src/lib/components/modes/tablemode/TableModeWelcome.scss b/src/lib/components/modes/tablemode/TableModeWelcome.scss index 9892b0f4..d158c8c6 100644 --- a/src/lib/components/modes/tablemode/TableModeWelcome.scss +++ b/src/lib/components/modes/tablemode/TableModeWelcome.scss @@ -36,10 +36,10 @@ } button.jse-nested-array-action { - @include jsoneditor-button-primary; - text-align: left; + @include jsoneditor-button-primary; + .jse-nested-array-count { opacity: 0.5; white-space: nowrap; diff --git a/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts b/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts index f510f525..6e6bb9fa 100644 --- a/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts +++ b/src/lib/components/modes/tablemode/contextmenu/createTableContextMenuItems.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem, DocumentState, JSONParser } from 'svelte-jsoneditor' +import type { ContextMenuItem, DocumentState, JSONSelection } from 'svelte-jsoneditor' import { faCheckSquare, faClone, @@ -11,7 +11,7 @@ import { faTrashCan } from '@fortawesome/free-solid-svg-icons' import { isKeySelection, isMultiSelection, isValueSelection } from '$lib/logic/selection' -import { compileJSONPointer, getIn } from 'immutable-json-patch' +import { getIn } from 'immutable-json-patch' import { getFocusPath, singleItemSelected } from '$lib/logic/selection' import { isObjectOrArray } from '$lib/utils/typeUtils' import { getEnforceString } from '$lib/logic/documentState' @@ -19,8 +19,8 @@ import { getEnforceString } from '$lib/logic/documentState' export default function ({ json, documentState, + selection, readOnly, - parser, onEditValue, onEditRow, onToggleEnforceString, @@ -34,9 +34,9 @@ export default function ({ onRemoveRow }: { json: unknown | undefined - documentState: DocumentState + documentState: DocumentState | undefined + selection: JSONSelection | undefined readOnly: boolean - parser: JSONParser onEditValue: () => void onEditRow: () => void onToggleEnforceString: () => void @@ -49,8 +49,6 @@ export default function ({ onInsertAfterRow: () => void onRemoveRow: () => void }): ContextMenuItem[] { - const selection = documentState.selection - const hasJson = json !== undefined const hasSelection = !!selection const focusValue = @@ -60,20 +58,14 @@ export default function ({ hasJson && (isMultiSelection(selection) || isKeySelection(selection) || isValueSelection(selection)) - const canEditValue = !readOnly && hasJson && selection != null && singleItemSelected(selection) + const canEditValue = + !readOnly && hasJson && selection !== undefined && singleItemSelected(selection) const canEnforceString = canEditValue && !isObjectOrArray(focusValue) const canCut = !readOnly && hasSelectionContents const enforceString = - selection != null && focusValue !== undefined - ? getEnforceString( - focusValue, - documentState.enforceStringMap, - compileJSONPointer(getFocusPath(selection)), - parser - ) - : false + selection !== undefined ? getEnforceString(json, documentState, getFocusPath(selection)) : false return [ { type: 'separator' }, diff --git a/src/lib/components/modes/tablemode/menu/TableMenu.svelte b/src/lib/components/modes/tablemode/menu/TableMenu.svelte index 5d697e6b..2143e024 100644 --- a/src/lib/components/modes/tablemode/menu/TableMenu.svelte +++ b/src/lib/components/modes/tablemode/menu/TableMenu.svelte @@ -93,7 +93,7 @@ ] let items: MenuItem[] - $: items = onRenderMenu(defaultItems) + $: items = onRenderMenu(defaultItems) || defaultItems diff --git a/src/lib/components/modes/tablemode/tag/InlineValue.scss b/src/lib/components/modes/tablemode/tag/InlineValue.scss index 59bfc007..a01a6011 100644 --- a/src/lib/components/modes/tablemode/tag/InlineValue.scss +++ b/src/lib/components/modes/tablemode/tag/InlineValue.scss @@ -8,15 +8,7 @@ padding: 0 $padding-half; background: transparent; color: inherit; - cursor: pointer; - - &:hover { - background: $hover-background-color; - } - - &.jse-selected { - background: $selection-background-color; - } + cursor: inherit; &.jse-highlight { background-color: $search-match-color; diff --git a/src/lib/components/modes/textmode/StatusBar.scss b/src/lib/components/modes/textmode/StatusBar.scss index 3ad6c8b1..67ed12a1 100644 --- a/src/lib/components/modes/textmode/StatusBar.scss +++ b/src/lib/components/modes/textmode/StatusBar.scss @@ -11,13 +11,13 @@ border-left: $main-border; border-right: $main-border; + display: flex; + gap: $padding; + &:last-child { border-bottom: $main-border; } - display: flex; - gap: $padding; - .jse-status-bar-info { padding: 2px; } diff --git a/src/lib/components/modes/textmode/TextMode.svelte b/src/lib/components/modes/textmode/TextMode.svelte index 17b1efa6..d182c673 100644 --- a/src/lib/components/modes/textmode/TextMode.svelte +++ b/src/lib/components/modes/textmode/TextMode.svelte @@ -121,12 +121,12 @@ export let statusBar: boolean export let askToFormat: boolean export let externalContent: Content - export let externalSelection: JSONEditorSelection | null + export let externalSelection: JSONEditorSelection | undefined export let indentation: number | string export let tabSize: number export let escapeUnicodeCharacters: boolean export let parser: JSONParser - export let validator: Validator | null + export let validator: Validator | undefined export let validationParser: JSONParser export let onChange: OnChange export let onChangeMode: OnChangeMode @@ -157,6 +157,7 @@ let onChangeDisabled = false let acceptTooLarge = false + let askToFormatApplied = askToFormat let validationErrors: ValidationError[] = [] const linterCompartment = new Compartment() @@ -288,6 +289,8 @@ setCodeMirrorContent(updatedContent, true, false) + askToFormatApplied = askToFormat // reset to the original value + return true } catch (err) { onError(err as Error) @@ -311,6 +314,8 @@ setCodeMirrorContent(updatedContent, true, false) + askToFormatApplied = false + return true } catch (err) { onError(err as Error) @@ -334,7 +339,7 @@ setCodeMirrorContent(updatedContent, true, false) jsonStatus = JSON_STATUS_VALID - jsonParseError = null + jsonParseError = undefined } catch (err) { onError(err as Error) } @@ -472,7 +477,7 @@ debug('select validation error', validationError) const { from, to } = toRichValidationError(validationError) - if (from === null || to === null) { + if (from === undefined || to === undefined) { return } @@ -634,7 +639,7 @@ : false } - function isValidSelection(selection: JSONEditorSelection | null, text: string): boolean { + function isValidSelection(selection: JSONEditorSelection | undefined, text: string): boolean { if (!isTextSelection(selection)) { return false } @@ -677,7 +682,7 @@ apply: () => handleRepair() } ] - : null + : undefined } } @@ -724,7 +729,7 @@ } } - function applyExternalSelection(externalSelection: JSONEditorSelection | null) { + function applyExternalSelection(externalSelection: JSONEditorSelection | undefined) { if (!isTextSelection(externalSelection)) { return } @@ -739,7 +744,7 @@ } function toCodeMirrorSelection( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): EditorSelection | undefined { return isTextSelection(selection) ? EditorSelection.fromJSON(selection) : undefined } @@ -798,7 +803,7 @@ tick().then(emitOnSelect) } - function updateLinter(validator: Validator | null) { + function updateLinter(validator: Validator | undefined) { debug('updateLinter', validator) if (!codeMirrorView) { @@ -891,7 +896,7 @@ if (onChange) { onChange(content, previousContent, { contentErrors: validate(), - patchResult: null + patchResult: undefined }) } } @@ -910,7 +915,7 @@ let jsonStatus = JSON_STATUS_VALID - let jsonParseError: ParseError | null = null + let jsonParseError: ParseError | undefined function linterCallback(): Diagnostic[] { if (disableTextEditor(text, acceptTooLarge)) { @@ -932,7 +937,7 @@ return [] } - export function validate(): ContentErrors | null { + export function validate(): ContentErrors | undefined { debug('validate:start') flush() @@ -950,7 +955,7 @@ validationErrors = [] } else { jsonStatus = JSON_STATUS_VALID - jsonParseError = null + jsonParseError = undefined validationErrors = contentErrors?.validationErrors || [] } @@ -1071,7 +1076,7 @@ /> {/if} - {#if !jsonParseError && askToFormat && needsFormatting(text)} + {#if !jsonParseError && askToFormatApplied && needsFormatting(text)} (askToFormat = false) + onClick: () => (askToFormatApplied = false) } ]} onClose={focus} diff --git a/src/lib/components/modes/treemode/CollapsedItems.scss b/src/lib/components/modes/treemode/CollapsedItems.scss index eef165f4..4c946620 100644 --- a/src/lib/components/modes/treemode/CollapsedItems.scss +++ b/src/lib/components/modes/treemode/CollapsedItems.scss @@ -39,6 +39,11 @@ div.jse-collapsed-items { display: flex; + &.jse-selected { + background-color: $selection-background-color; + --jse-collapsed-items-background-color: #{$collapsed-items-selected-background-color}; + } + div.jse-text, button.jse-expand-items { margin: 0 $padding-half; diff --git a/src/lib/components/modes/treemode/CollapsedItems.svelte b/src/lib/components/modes/treemode/CollapsedItems.svelte index 542800d3..9ae5a078 100644 --- a/src/lib/components/modes/treemode/CollapsedItems.svelte +++ b/src/lib/components/modes/treemode/CollapsedItems.svelte @@ -10,7 +10,7 @@ export let sectionIndex: number export let total: number export let path: JSONPath - export let selection: JSONSelection | null + export let selection: JSONSelection | undefined export let onExpandSection: (path: JSONPath, section: Section) => void export let context: JSONEditorContext diff --git a/src/lib/components/modes/treemode/JSONKey.scss b/src/lib/components/modes/treemode/JSONKey.scss index 33f7a9c9..f842022e 100644 --- a/src/lib/components/modes/treemode/JSONKey.scss +++ b/src/lib/components/modes/treemode/JSONKey.scss @@ -9,20 +9,11 @@ border-radius: 1px; vertical-align: top; color: $key-color; - cursor: $contents-cursor; word-break: normal; overflow-wrap: normal; // not anywhere as for JSONValue white-space: pre-wrap; // important for rendering multiple consecutive spaces - &:hover { - background: $hover-background-color; - } - - &:hover { - background: $hover-background-color; - } - &.jse-empty { min-width: 3em; outline: 1px dotted $tag-background; diff --git a/src/lib/components/modes/treemode/JSONKey.svelte b/src/lib/components/modes/treemode/JSONKey.svelte index d53c4626..cb33f7bf 100644 --- a/src/lib/components/modes/treemode/JSONKey.svelte +++ b/src/lib/components/modes/treemode/JSONKey.svelte @@ -3,6 +3,7 @@ @@ -67,6 +64,8 @@ {#if !context.readOnly && isEditingKey} {#if searchResultItems} diff --git a/src/lib/components/modes/treemode/JSONNode.scss b/src/lib/components/modes/treemode/JSONNode.scss index 91f23912..2d3584eb 100644 --- a/src/lib/components/modes/treemode/JSONNode.scss +++ b/src/lib/components/modes/treemode/JSONNode.scss @@ -1,5 +1,70 @@ @import '../../../styles'; +.jse-expand { + width: $indent-size; + padding: 0; + margin: 0; + border: none; + cursor: pointer; + background: transparent; + color: $delimiter-color; + font-size: $font-size-mono; + height: $line-height; + + &:hover { + opacity: 0.8; + } +} + +.jse-meta, +.jse-separator, +.jse-index, +.jse-bracket { + vertical-align: top; + color: $delimiter-color; +} + +.jse-index { + padding: 0 $padding-half; +} + +.jse-bracket { + padding: 0 2px; + + &.jse-expanded { + padding-right: $padding; + } +} + +.jse-tag { + border: none; + font-size: 80%; + font-family: $font-family; + color: $tag-color; + background: $tag-background; + border-radius: 2px; + cursor: pointer; + //position: relative; + display: inline-block; + padding: 0 4px; + line-height: normal; + margin: 1px 0; + + &:hover { + opacity: 0.8; + } + + &.jse-expanded { + opacity: 0.7; + cursor: inherit; + } +} + +.jse-identifier { + vertical-align: top; + position: relative; +} + .jse-json-node { position: relative; color: $text-color; @@ -9,12 +74,6 @@ padding-bottom: $contents-padding; box-sizing: border-box; - > .jse-header-outer :global(.jse-context-menu-pointer), - > .jse-contents-outer > .jse-contents :global(.jse-context-menu-pointer) { - top: 0; - right: calc(-2px - $context-menu-pointer-size); - } - > .jse-contents-outer > .jse-contents { padding-left: 0; } @@ -58,6 +117,11 @@ .jse-contents { padding-left: $indent-size; + cursor: $contents-cursor; + + .jse-value-outer { + display: inline-flex; + } } .jse-footer { @@ -72,7 +136,6 @@ } .jse-insert-selection-area { - visibility: hidden; padding: 0 $padding-half; flex: 1; // must fill all left over space at the right side of the editor, so you can click there @@ -106,97 +169,88 @@ margin-right: $padding-half; outline: $height-half solid; // color depends on hovered/selected/inactive+selected - :global(.jse-context-menu-pointer) { - right: -$height-half; - background: $context-menu-pointer-hover-background; - } - &.jse-hovered { outline-color: $context-menu-pointer-hover-background; } } - &:hover > .jse-contents-outer .jse-insert-selection-area:not(.jse-selected), - .jse-header-outer:hover > .jse-insert-selection-area:not(.jse-selected), - .jse-footer-outer:hover .jse-insert-selection-area:not(.jse-selected) { - visibility: visible; + .jse-key-outer { + position: relative; } - &.jse-hovered { - > .jse-header-outer > .jse-header > .jse-meta, - .jse-props .jse-header, + .jse-key-outer, + .jse-value-outer, + .jse-meta, + .jse-footer { + &:hover { + background: $hover-background-color; + cursor: $contents-cursor; + } + } + + &.jse-hovered:not(.jse-selected):not(.jse-selected-value) { + .jse-value-outer, + .jse-meta, .jse-items .jse-header, - .jse-props .jse-contents, .jse-items .jse-contents, + .jse-props .jse-header, + .jse-props .jse-contents, .jse-footer { background: $hover-background-color; + cursor: $contents-cursor; + + .jse-value-outer, + .jse-meta { + // since the $hover-background-color is half transparent, + // we have to prevent it from being applied twice, else it gets a darker color + background: none; + } } } - // TODO: simplify the styling for selected keys/values/multi (get rid of globals?) - - // entry selected (key and value) + // key and value selected &.jse-selected { - > .jse-header-outer > .jse-header > .jse-meta, - .jse-props .jse-header, - .jse-items .jse-header, - .jse-props .jse-contents, - .jse-items .jse-contents, .jse-header, .jse-contents, - .jse-footer, - :global(.jse-key), - :global(.jse-value) { + .jse-footer { background: $selection-background-color; cursor: $contents-selected-cursor; } - .jse-expand { - background: $selection-background-color; + .jse-key-outer, + .jse-value-outer, + .jse-meta, + .jse-footer { + &:hover { + background: inherit; + cursor: inherit; + } } } // key selected - &.jse-selected-key { - > .jse-contents-outer > .jse-contents > :global(.jse-identifier) > :global(.jse-key), - > .jse-header-outer > .jse-header > :global(.jse-identifier) > :global(.jse-key) { - background: $selection-background-color; - cursor: $contents-selected-cursor; - } - } - - // value selected (part 1) - &.jse-selected-value > .jse-contents-outer > .jse-contents > :global(.jse-value) { + .jse-key-outer.jse-selected-key { background: $selection-background-color; cursor: $contents-selected-cursor; } - :global(.jse-collapsed-items.jse-selected), - &.jse-selected :global(.jse-collapsed-items), - &.jse-selected-value :global(.jse-collapsed-items) { - background-color: $selection-background-color; - --jse-collapsed-items-background-color: #{$collapsed-items-selected-background-color}; - } - - // value selected (part 2) + // value selected &.jse-selected-value { + .jse-value-outer, .jse-meta, - > .jse-header-outer > .jse-header > .jse-meta, - > .jse-footer-outer > .jse-footer, - .jse-props .jse-contents, - .jse-props .jse-header, - .jse-props .jse-footer, - .jse-props .jse-expand, - .jse-items .jse-contents, .jse-items .jse-header, - .jse-items .jse-footer, - .jse-items .jse-expand { + .jse-items .jse-contents, + .jse-props .jse-header, + .jse-props .jse-contents, + .jse-footer { background: $selection-background-color; + cursor: $contents-selected-cursor; - :global(.jse-key), - :global(.jse-value) { - background: $selection-background-color; - cursor: $contents-selected-cursor; + .jse-key-outer { + &:hover { + background: inherit; + cursor: inherit; + } } } } @@ -210,89 +264,4 @@ outline-color: $context-menu-pointer-background; } } - - .jse-insert-area { - &.jse-selected { - :global(.jse-context-menu-pointer) { - background: $context-menu-pointer-background; - - &:hover { - background: $context-menu-pointer-background-highlight; - } - } - } - } -} - -// lighter selection color when the editor doesn't have focus -:global(.jse-main:not(.jse-focus)) { - .jse-json-node { - --jse-selection-background-color: #{$selection-background-inactive-color}; - --jse-context-menu-pointer-background: #{$context-menu-pointer-hover-background}; - } -} - -.jse-expand { - width: $indent-size; - padding: 0; - margin: 0; - border: none; - cursor: pointer; - background: transparent; - color: $delimiter-color; - font-size: $font-size-mono; - height: $line-height; - - &:hover { - opacity: 0.8; - } -} - -.jse-meta, -.jse-separator, -.jse-index, -.jse-bracket { - vertical-align: top; - color: $delimiter-color; -} - -.jse-index { - padding: 0 $padding-half; -} - -.jse-bracket { - padding: 0 2px; - - &.jse-expanded { - padding-right: $padding; - } -} - -.jse-tag { - border: none; - font-size: 80%; - font-family: $font-family; - color: $tag-color; - background: $tag-background; - border-radius: 2px; - cursor: pointer; - //position: relative; - display: inline-block; - padding: 0 4px; - line-height: normal; - margin: 1px 0; - - &:hover { - opacity: 0.8; - } - - &.jse-expanded { - opacity: 0.7; - cursor: inherit; - } -} - -.jse-identifier { - vertical-align: top; - position: relative; } diff --git a/src/lib/components/modes/treemode/JSONNode.svelte b/src/lib/components/modes/treemode/JSONNode.svelte index 705463f6..9b7205b0 100644 --- a/src/lib/components/modes/treemode/JSONNode.svelte +++ b/src/lib/components/modes/treemode/JSONNode.svelte @@ -3,8 +3,8 @@ diff --git a/src/lib/components/modes/treemode/singleton.ts b/src/lib/components/modes/treemode/singleton.ts index 8f8e60fd..3f04d1df 100644 --- a/src/lib/components/modes/treemode/singleton.ts +++ b/src/lib/components/modes/treemode/singleton.ts @@ -3,18 +3,18 @@ import type { JSONPath } from 'immutable-json-patch' export const singleton: Singleton = { selecting: false, - selectionAnchor: null, // Path - selectionAnchorType: null, // Selection type - selectionFocus: null, // Path + selectionAnchor: undefined, // Path + selectionAnchorType: undefined, // Selection type + selectionFocus: undefined, // Path dragging: false } interface Singleton { selecting: boolean - selectionAnchor: JSONPath | null - selectionAnchorType: string | null - selectionFocus: JSONPath | null + selectionAnchor: JSONPath | undefined + selectionAnchorType: string | undefined + selectionFocus: JSONPath | undefined dragging: boolean } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a6d3673f..e6102fdf 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,29 +20,6 @@ export const MAX_AUTO_REPAIRABLE_SIZE = 1024 * 1024 // 1 MB export const MAX_DOCUMENT_SIZE_TEXT_MODE = 10 * 1024 * 1024 // 10 MB export const MAX_DOCUMENT_SIZE_EXPAND_ALL = 10 * 1024 // 10 KB -export const SIMPLE_MODAL_OPTIONS = { - closeButton: false, - classBg: 'jse-modal-bg', - classWindow: 'jse-modal-window', - classWindowWrap: 'jse-modal-window-wrap', - classContent: 'jse-modal-container' -} - -export const SORT_MODAL_OPTIONS = { - ...SIMPLE_MODAL_OPTIONS, - classWindow: 'jse-modal-window jse-modal-window-sort' -} - -export const TRANSFORM_MODAL_OPTIONS = { - ...SIMPLE_MODAL_OPTIONS, - classWindow: 'jse-modal-window jse-modal-window-transform' -} - -export const JSONEDITOR_MODAL_OPTIONS = { - ...SIMPLE_MODAL_OPTIONS, - classWindow: 'jse-modal-window jse-modal-window-jsoneditor' -} - export const INSERT_EXPLANATION = 'Insert or paste contents, ' + 'enter [ insert a new array, ' + diff --git a/src/lib/index-vanilla.ts b/src/lib/index-vanilla.ts new file mode 100644 index 00000000..d686e981 --- /dev/null +++ b/src/lib/index-vanilla.ts @@ -0,0 +1,36 @@ +import JSONEditorComponent from './components/JSONEditor.svelte' +import type { JSONEditorPropsOptional } from '$lib/types' + +// Note: index.ts exports `JSONEditor`, but we will override this on purpose +// since we cannot use it in the vanilla environment starting in Svelte 5. +export * from './index' + +interface CreateJSONEditorProps { + target: HTMLDivElement + props: JSONEditorPropsOptional +} + +export function createJSONEditor({ target, props }: CreateJSONEditorProps) { + // TODO: in Svelte 5, this needs to be changed to: + // + // export function createJSONEditor({ target, props }: Parameters[1]) { + // return mount(JSONEditor, { target, props }) + // } + // + return new JSONEditorComponent({ target, props }) +} + +/** + * The constructor "new JSONEditor(...)" is deprecated. Please use "createJSONEditor(...)" instead. + * @constructor + * @deprecated + */ +export function JSONEditor({ target, props }: CreateJSONEditorProps) { + // TODO: deprecation warning since v1. Remove some day + console.warn( + 'WARNING: the constructor "new JSONEditor(...)" is deprecated since v1. ' + + 'Please use "createJSONEditor(...)" instead.' + ) + + return createJSONEditor({ target, props }) +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 55faa2d3..4a49c2f0 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -8,20 +8,25 @@ import TimestampTag from './plugins/value/components/TimestampTag.svelte' // editor export { JSONEditor } -export * from './types.js' // value plugins export { renderValue } from './plugins/value/renderValue.js' export { renderJSONSchemaEnum } from './plugins/value/renderJSONSchemaEnum.js' export { BooleanToggle, ColorPicker, EditableValue, EnumValue, ReadonlyValue, TimestampTag } +// HTML +export { getValueClass } from './plugins/value/components/utils/getValueClass' +export { keyComboFromEvent } from './utils/keyBindings' + // validator plugins export * from './plugins/validator/createAjvValidator.js' // query plugins +export { jsonQueryLanguage } from './plugins/query/jsonQueryLanguage.js' +export { jmespathQueryLanguage } from './plugins/query/jmespathQueryLanguage.js' +export { jsonpathQueryLanguage } from './plugins/query/jsonpathQueryLanguage.js' export { lodashQueryLanguage } from './plugins/query/lodashQueryLanguage.js' export { javascriptQueryLanguage } from './plugins/query/javascriptQueryLanguage.js' -export { jmespathQueryLanguage } from './plugins/query/jmespathQueryLanguage.js' // content export { @@ -34,6 +39,9 @@ export { estimateSerializedSize } from './utils/jsonUtils.js' +// expand +export { expandAll, expandMinimal, expandNone, expandSelf } from './logic/documentState' + // selection export { isValueSelection, @@ -43,10 +51,17 @@ export { isMultiSelection, isEditingSelection, createValueSelection, + createEditValueSelection, createKeySelection, + createEditKeySelection, createInsideSelection, createAfterSelection, - createMultiSelection + createMultiSelection, + getFocusPath, + getAnchorPath, + getStartPath, + getEndPath, + getSelectionPaths } from './logic/selection.js' // parser @@ -59,5 +74,20 @@ export { parseJSONPath, stringifyJSONPath } from './utils/pathUtils.js' export { resizeObserver } from './actions/resizeObserver.js' export { onEscape } from './actions/onEscape.js' +// type checking +export { + valueType, + stringConvert, + isObject, + isObjectOrArray, + isBoolean, + isTimestamp, + isColor, + isUrl +} from './utils/typeUtils' + +// types +export * from './types.js' + // typeguards export * from './typeguards.js' diff --git a/src/lib/logic/actions.ts b/src/lib/logic/actions.ts index a310abd9..a5f6962a 100644 --- a/src/lib/logic/actions.ts +++ b/src/lib/logic/actions.ts @@ -1,11 +1,11 @@ import { + createEditKeySelection, + createEditValueSelection, createInsideSelection, - createKeySelection, createMultiSelection, createValueSelection, getFocusPath, hasSelectionContents, - isEditingSelection, isKeySelection, isValueSelection, selectionToPartialJson @@ -22,7 +22,6 @@ import { } from '$lib/logic/operations.js' import type { AfterPatchCallback, - DocumentState, InsertType, JSONParser, JSONSelection, @@ -41,21 +40,15 @@ 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, expandNone, expandPath, expandSmart } 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' const debug = createDebug('jsoneditor:actions') export interface OnCutAction { json: unknown | undefined - documentState: DocumentState + selection: JSONSelection | undefined indentation: string | number | undefined readOnly: boolean parser: JSONParser @@ -65,51 +58,44 @@ export interface OnCutAction { // TODO: write unit tests export async function onCut({ json, - documentState, + selection, indentation, readOnly, parser, onPatch }: OnCutAction) { - if ( - readOnly || - json === undefined || - !documentState.selection || - !hasSelectionContents(documentState.selection) - ) { + if (readOnly || json === undefined || !selection || !hasSelectionContents(selection)) { return } - const clipboard = selectionToPartialJson(json, documentState.selection, indentation, parser) - if (clipboard == null) { + const clipboard = selectionToPartialJson(json, selection, indentation, parser) + if (clipboard === undefined) { return } - debug('cut', { selection: documentState.selection, clipboard, indentation }) + debug('cut', { selection, clipboard, indentation }) await copyToClipboard(clipboard) - const { operations, newSelection } = createRemoveOperations(json, documentState.selection) + const { operations, newSelection } = createRemoveOperations(json, selection) - onPatch(operations, (patchedJson, patchedState) => ({ - state: { - ...patchedState, - selection: newSelection - } + onPatch(operations, (_, patchedState) => ({ + state: patchedState, + selection: newSelection })) } export interface OnCopyAction { json: unknown - documentState: DocumentState + selection: JSONSelection | undefined indentation: string | number | undefined parser: JSONParser } // TODO: write unit tests -export async function onCopy({ json, documentState, indentation, parser }: OnCopyAction) { - const clipboard = selectionToPartialJson(json, documentState.selection, indentation, parser) - if (clipboard == null) { +export async function onCopy({ json, selection, indentation, parser }: OnCopyAction) { + const clipboard = selectionToPartialJson(json, selection, indentation, parser) + if (clipboard === undefined) { return } @@ -123,7 +109,7 @@ type RepairModalCallback = (text: string, onApply: (repairedText: string) => voi interface OnPasteAction { clipboardText: string json: unknown | undefined - selection: JSONSelection | null + selection: JSONSelection | undefined readOnly: boolean parser: JSONParser onPatch: OnPatch @@ -148,11 +134,11 @@ export function onPaste({ function doPaste(pastedText: string) { if (json !== undefined) { - const selectionNonNull = selection || createValueSelection([], false) + const ensureSelection = selection || createValueSelection([]) - const operations = insert(json, selectionNonNull, pastedText, parser) + const operations = insert(json, ensureSelection, pastedText, parser) - debug('paste', { pastedText, operations, selectionNonNull }) + debug('paste', { pastedText, operations, ensureSelection }) onPatch(operations, (patchedJson, patchedState) => { let updatedState = patchedState @@ -166,7 +152,7 @@ export function onPaste({ ) .forEach((operation) => { const path = parsePath(json, operation.path) - updatedState = expandRecursive(patchedJson, updatedState, path) + updatedState = expandSmart(patchedJson, updatedState, path) }) return { @@ -181,9 +167,11 @@ export function onPaste({ if (patchedJson) { const path: JSONPath = [] return { - state: expandRecursive(patchedJson, patchedState, path) as DocumentState + state: expandSmart(patchedJson, patchedState, path) } } + + return undefined }) } } @@ -201,7 +189,7 @@ export function onPaste({ export interface OnRemoveAction { json: unknown | undefined text: string | undefined - documentState: DocumentState + selection: JSONSelection | undefined keepSelection: boolean readOnly: boolean onChange: OnChange @@ -212,35 +200,34 @@ export interface OnRemoveAction { export function onRemove({ json, text, - documentState, + selection, keepSelection, readOnly, onChange, onPatch }: OnRemoveAction) { - if (readOnly || !documentState.selection) { + if (readOnly || !selection) { return } // in case of a selected key or value, we change the selection to the whole // entry to remove this, we do not want to clear a key or value only. const removeSelection = - json !== undefined && - (isKeySelection(documentState.selection) || isValueSelection(documentState.selection)) - ? createMultiSelection(documentState.selection.path, documentState.selection.path) - : documentState.selection + json !== undefined && (isKeySelection(selection) || isValueSelection(selection)) + ? createMultiSelection(selection.path, selection.path) + : selection - if (isEmpty(getFocusPath(documentState.selection))) { + if (isEmpty(getFocusPath(selection))) { // root selected -> clear complete document - debug('remove root', { selection: documentState.selection }) + debug('remove root', { selection }) if (onChange) { onChange( { text: '', json: undefined }, json !== undefined ? { text: undefined, json } : { text: text || '', json }, { - contentErrors: null, - patchResult: null + contentErrors: undefined, + patchResult: undefined } ) } @@ -249,13 +236,11 @@ export function onRemove({ if (json !== undefined) { const { operations, newSelection } = createRemoveOperations(json, removeSelection) - debug('remove', { operations, selection: documentState.selection, newSelection }) + debug('remove', { operations, selection, newSelection }) - onPatch(operations, (patchedJson, patchedState) => ({ - state: { - ...patchedState, - selection: keepSelection ? documentState.selection : newSelection - } + onPatch(operations, (_, patchedState) => ({ + state: patchedState, + selection: keepSelection ? selection : newSelection })) } } @@ -263,7 +248,7 @@ export function onRemove({ export interface OnDuplicateRowAction { json: unknown | undefined - documentState: DocumentState + selection: JSONSelection | undefined columns: JSONPath[] readOnly: boolean onPatch: OnPatch @@ -276,47 +261,37 @@ export interface OnDuplicateRowAction { // TODO: write unit tests export function onDuplicateRow({ json, - documentState, + selection, columns, readOnly, onPatch }: OnDuplicateRowAction) { - if ( - readOnly || - json === undefined || - !documentState.selection || - !hasSelectionContents(documentState.selection) - ) { + if (readOnly || json === undefined || !selection || !hasSelectionContents(selection)) { return } - const { rowIndex, columnIndex } = toTableCellPosition( - getFocusPath(documentState.selection), - columns - ) + const { rowIndex, columnIndex } = toTableCellPosition(getFocusPath(selection), columns) debug('duplicate row', { rowIndex }) const rowPath = [String(rowIndex)] const operations = duplicate(json, [rowPath]) - onPatch(operations, (patchedJson, patchedState) => { + onPatch(operations, (_, patchedState) => { const newRowIndex = rowIndex < (json as Array).length ? rowIndex + 1 : rowIndex const newPath = fromTableCellPosition({ rowIndex: newRowIndex, columnIndex }, columns) - const newSelection = createValueSelection(newPath, false) + const newSelection = createValueSelection(newPath) return { - state: { - ...patchedState, - selection: newSelection - } + state: patchedState, + selection: newSelection } }) } export interface OnInsertBeforeRowAction { json: unknown | undefined - documentState: DocumentState + selection: JSONSelection | undefined columns: JSONPath[] readOnly: boolean onPatch: OnPatch @@ -329,21 +304,16 @@ export interface OnInsertBeforeRowAction { // TODO: write unit tests export function onInsertBeforeRow({ json, - documentState, + selection, columns, readOnly, onPatch }: OnInsertBeforeRowAction) { - if ( - readOnly || - json === undefined || - !documentState.selection || - !hasSelectionContents(documentState.selection) - ) { + if (readOnly || json === undefined || !selection || !hasSelectionContents(selection)) { return } - const { rowIndex } = toTableCellPosition(getFocusPath(documentState.selection), columns) + const { rowIndex } = toTableCellPosition(getFocusPath(selection), columns) debug('insert before row', { rowIndex }) @@ -357,7 +327,7 @@ export function onInsertBeforeRow({ export interface OnInsertAfterRowAction { json: unknown | undefined - documentState: DocumentState + selection: JSONSelection | undefined columns: JSONPath[] readOnly: boolean onPatch: OnPatch @@ -370,24 +340,16 @@ export interface OnInsertAfterRowAction { // TODO: write unit tests export function onInsertAfterRow({ json, - documentState, + selection, columns, readOnly, onPatch }: OnInsertAfterRowAction) { - if ( - readOnly || - json === undefined || - !documentState.selection || - !hasSelectionContents(documentState.selection) - ) { + if (readOnly || json === undefined || !selection || !hasSelectionContents(selection)) { return } - const { rowIndex, columnIndex } = toTableCellPosition( - getFocusPath(documentState.selection), - columns - ) + const { rowIndex, columnIndex } = toTableCellPosition(getFocusPath(selection), columns) debug('insert after row', { rowIndex }) @@ -401,22 +363,20 @@ export function onInsertAfterRow({ ? insertBefore(json, nextRowPath, values) : append(json, [], values) - onPatch(operations, (patchedJson, patchedState) => { + onPatch(operations, (_, patchedState) => { const nextPath = fromTableCellPosition({ rowIndex: nextRowIndex, columnIndex }, columns) - const newSelection = createValueSelection(nextPath, false) + const newSelection = createValueSelection(nextPath) return { - state: { - ...patchedState, - selection: newSelection - } + state: patchedState, + selection: newSelection } }) } export interface OnRemoveRowAction { json: unknown | undefined - documentState: DocumentState + selection: JSONSelection | undefined columns: JSONPath[] readOnly: boolean onPatch: OnPatch @@ -427,26 +387,12 @@ export interface OnRemoveRowAction { * it cannot duplicate something in some nested array */ // TODO: write unit tests -export function onRemoveRow({ - json, - documentState, - columns, - readOnly, - onPatch -}: OnRemoveRowAction) { - if ( - readOnly || - json === undefined || - !documentState.selection || - !hasSelectionContents(documentState.selection) - ) { +export function onRemoveRow({ json, selection, columns, readOnly, onPatch }: OnRemoveRowAction) { + if (readOnly || json === undefined || !selection || !hasSelectionContents(selection)) { return } - const { rowIndex, columnIndex } = toTableCellPosition( - getFocusPath(documentState.selection), - columns - ) + const { rowIndex, columnIndex } = toTableCellPosition(getFocusPath(selection), columns) debug('remove row', { rowIndex }) @@ -464,18 +410,15 @@ export function onRemoveRow({ const newSelection = newRowIndex !== undefined ? createValueSelection( - fromTableCellPosition({ rowIndex: newRowIndex, columnIndex }, columns), - false + fromTableCellPosition({ rowIndex: newRowIndex, columnIndex }, columns) ) - : null + : undefined debug('remove row new selection', { rowIndex, newRowIndex, newSelection }) return { - state: { - ...patchedState, - selection: newSelection - } + state: patchedState, + selection: newSelection } }) } @@ -483,9 +426,9 @@ export function onRemoveRow({ export interface OnInsert { insertType: InsertType selectInside: boolean - refJsonEditor: HTMLElement json: unknown | undefined - selection: JSONSelection | null + selection: JSONSelection | undefined + initialValue: string | undefined readOnly: boolean parser: JSONParser onPatch: OnPatch @@ -496,7 +439,7 @@ export interface OnInsert { export function onInsert({ insertType, selectInside, - refJsonEditor, + initialValue, json, selection, readOnly, @@ -519,63 +462,45 @@ export function onInsert({ operations.filter((operation) => operation.op === 'add' || operation.op === 'replace') ) - onPatch(operations, (patchedJson, patchedState) => { + onPatch(operations, (patchedJson, patchedState, patchedSelection) => { // TODO: extract determining the newSelection in a separate function if (operation) { const path = parsePath(patchedJson, operation.path) if (isObjectOrArray(newValue)) { return { - state: { - ...expandWithCallback(patchedJson, patchedState, path, expandAll), - selection: selectInside ? createInsideSelection(path) : patchedState.selection - } + state: expandPath(patchedJson, patchedState, path, expandAll), + selection: selectInside ? createInsideSelection(path) : patchedSelection } } if (newValue === '') { // open the newly inserted value in edit mode - const parent = !isEmpty(path) ? getIn(patchedJson, initial(path)) : null + 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, - selection: isObject(parent) - ? createKeySelection(path, true) - : createValueSelection(path, true) - }, - path - ) + state: expandPath(patchedJson, patchedState, path, expandNone), + selection: isObject(parent) + ? createEditKeySelection(path, initialValue) + : createEditValueSelection(path, initialValue) } } - - return undefined } + + return undefined }) debug('after patch') - - if (operation) { - if (newValue === '') { - // open the newly inserted value in edit mode (can be cancelled via ESC this way) - tick2(() => insertActiveElementContents(refJsonEditor, '', true, refreshEditableDiv)) - } - } } else { // document is empty or invalid (in that case it has text but no json) debug('onInsert', { insertType, newValue }) const path: JSONPath = [] onReplaceJson(newValue, (patchedJson, patchedState) => ({ - state: { - ...expandRecursive(patchedJson, patchedState, path), - selection: isObjectOrArray(newValue) - ? createInsideSelection(path) - : createValueSelection(path, true) - } + state: expandSmart(patchedJson, patchedState, path), + selection: isObjectOrArray(newValue) + ? createInsideSelection(path) + : createEditValueSelection(path) })) } } @@ -583,9 +508,8 @@ export function onInsert({ export interface OnInsertCharacter { char: string selectInside: boolean - refJsonEditor: HTMLElement json: unknown | undefined - selection: JSONSelection | null + selection: JSONSelection | undefined readOnly: boolean parser: JSONParser onPatch: OnPatch @@ -597,7 +521,6 @@ export interface OnInsertCharacter { export async function onInsertCharacter({ char, selectInside, - refJsonEditor, json, selection, readOnly, @@ -606,22 +529,14 @@ export async function onInsertCharacter({ onReplaceJson, onSelect }: OnInsertCharacter) { - // a regular key like a, A, _, etc is entered. + // a regular key like a, A, _, etc. is entered. // Replace selected contents with a new value having this first character as text if (readOnly) { return } if (isKeySelection(selection)) { - // only replace contents when not yet in edit mode (can happen when entering - // multiple characters very quickly after each other due to the async handling) - const replaceContents = !selection.edit - - onSelect({ ...selection, edit: true }) - tick2(() => - // We use this way via insertActiveElementContents, so we can cancel via ESC - insertActiveElementContents(refJsonEditor, char, replaceContents, refreshEditableDiv) - ) + onSelect({ ...selection, edit: true, initialValue: char }) return } @@ -629,7 +544,7 @@ export async function onInsertCharacter({ onInsert({ insertType: 'object', selectInside, - refJsonEditor, + initialValue: undefined, // not relevant json, selection, readOnly, @@ -641,7 +556,7 @@ export async function onInsertCharacter({ onInsert({ insertType: 'array', selectInside, - refJsonEditor, + initialValue: undefined, // not relevant json, selection, readOnly, @@ -652,15 +567,7 @@ export async function onInsertCharacter({ } else { if (isValueSelection(selection) && json !== undefined) { if (!isObjectOrArray(getIn(json, selection.path))) { - // only replace contents when not yet in edit mode (can happen when entering - // multiple characters very quickly after each other due to the async handling) - const replaceContents = !selection.edit - - onSelect({ ...selection, edit: true }) - tick2(() => - // We use this way via insertActiveElementContents, so we can cancel via ESC - insertActiveElementContents(refJsonEditor, char, replaceContents, refreshEditableDiv) - ) + onSelect({ ...selection, edit: true, initialValue: char }) } else { // TODO: replace the object/array with editing a text in edit mode? // (Ideally this this should not create an entry in history though, @@ -671,7 +578,6 @@ export async function onInsertCharacter({ debug('onInsertValueWithCharacter', { char }) await onInsertValueWithCharacter({ char, - refJsonEditor, json, selection, readOnly, @@ -685,9 +591,8 @@ export async function onInsertCharacter({ interface OnInsertValueWithCharacter { char: string - refJsonEditor: HTMLElement json: unknown | undefined - selection: JSONSelection | null + selection: JSONSelection | undefined readOnly: boolean parser: JSONParser onPatch: OnPatch @@ -696,7 +601,6 @@ interface OnInsertValueWithCharacter { async function onInsertValueWithCharacter({ char, - refJsonEditor, json, selection, readOnly, @@ -712,7 +616,7 @@ async function onInsertValueWithCharacter({ onInsert({ insertType: 'value', selectInside: false, // not relevant, we insert a value, not an object or array - refJsonEditor, + initialValue: char, json, selection, readOnly, @@ -720,29 +624,4 @@ async function onInsertValueWithCharacter({ onPatch, onReplaceJson }) - - // only replace contents when not yet in edit mode (can happen when entering - // multiple characters very quickly after each other due to the async handling) - const replaceContents = !isEditingSelection(selection) - - tick2(() => insertActiveElementContents(refJsonEditor, char, replaceContents, refreshEditableDiv)) -} - -/** - * set two timeouts, two ticks of delay. - * This allows to perform some action in the DOM *after* Svelte has re-rendered the app for example - * WARNING: try to avoid using this function, it is tricky to rely on it. - */ -function tick2(callback: () => void) { - setTimeout(() => setTimeout(callback)) -} - -function refreshEditableDiv(element: HTMLElement) { - // We force a refresh because when changing the text of the editable div programmatically, - // the DIV doesn't get a trigger to update it's class - // TODO: come up with a better solution - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - element?.refresh() } diff --git a/src/lib/logic/documentState.test.ts b/src/lib/logic/documentState.test.ts index 359bd31c..0607bd56 100644 --- a/src/lib/logic/documentState.test.ts +++ b/src/lib/logic/documentState.test.ts @@ -1,32 +1,62 @@ -import assert, { deepStrictEqual } from 'assert' -import { describe, test } from 'vitest' +import assert from 'assert' +import { describe, expect, test } from 'vitest' import { flatMap, isEqual, range, times } from 'lodash-es' -import { ARRAY_SECTION_SIZE } from '../constants.js' +import { ARRAY_SECTION_SIZE, DEFAULT_VISIBLE_SECTIONS } from '../constants.js' import { collapsePath, + createArrayDocumentState, createDocumentState, - deletePath, + createObjectDocumentState, + createValueDocumentState, + deleteInDocumentState, + documentStateFactory, documentStatePatch, + ensureRecursiveState, + expandAll, + expandNone, expandPath, expandSection, - expandWithCallback, - filterPath, + expandSelf, + expandSmart, forEachVisibleIndex, getEnforceString, + getInRecursiveState, getVisibleCaretPositions, getVisiblePaths, - shiftPath, shiftVisibleSections, - syncKeys + syncDocumentState, + syncKeys, + toRecursiveStatePath, + updateInDocumentState } from './documentState.js' import { + type ArrayDocumentState, CaretType, type DocumentState, - type JSONPointerMap, + type ObjectDocumentState, + type OnExpand, + type ValueDocumentState, type VisibleSection } from '$lib/types.js' -import type { JSONPatchDocument } from 'immutable-json-patch' -import { compileJSONPointer, deleteIn, setIn } from 'immutable-json-patch' +import { deleteIn, getIn, type JSONPatchDocument, setIn, updateIn } from 'immutable-json-patch' +import { isArrayRecursiveState } from 'svelte-jsoneditor' + +const json3 = [{ id: 0 }, { id: 1 }, { id: 2 }] +const documentState3: DocumentState = { + type: 'array', + expanded: true, + items: initArray([ + 1, + { + type: 'object', + expanded: false, + properties: { + id: { type: 'value', enforceString: true } + } + } + ]), + visibleSections: DEFAULT_VISIBLE_SECTIONS +} describe('documentState', () => { test('syncKeys should append new keys and remove old keys', () => { @@ -37,79 +67,563 @@ describe('documentState', () => { assert.deepStrictEqual(syncKeys(['a', 'b'], ['b', 'a']), ['b', 'a']) }) - describe('expandWithCallback', () => { + describe('ensureNestedDocumentState', () => { + test('should create nested state in an array', () => { + const expected: DocumentState = { + type: 'array', + expanded: false, + items: initArray([1, { type: 'value' }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual( + ensureRecursiveState([1, 2, 3], undefined, ['1'], documentStateFactory), + expected + ) + }) + + test('should maintain state when creating nested state in an array', () => { + const state: DocumentState = { + type: 'array', + expanded: true, + items: [], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + const expected: DocumentState = { + type: 'array', + expanded: true, + items: initArray([1, { type: 'value' }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual( + ensureRecursiveState([1, 2, 3], state, ['1'], documentStateFactory), + expected + ) + }) + + test('should create nested state in an object', () => { + const expected: DocumentState = { + type: 'object', + expanded: false, + properties: { + a: { type: 'value' } + } + } + + assert.deepStrictEqual( + ensureRecursiveState({ a: 2, b: 3 }, undefined, ['a'], documentStateFactory), + expected + ) + }) + + test('should maintain state when creating nested state in an object', () => { + const state: DocumentState = { + type: 'object', + expanded: true, + properties: {} + } + + const expected: DocumentState = { + type: 'object', + expanded: true, + properties: { + a: { type: 'value' } + } + } + + assert.deepStrictEqual( + ensureRecursiveState({ a: 2, b: 3 }, state, ['a'], documentStateFactory), + expected + ) + }) + + test('should create nested state in an object and array', () => { + const expected: DocumentState = { + type: 'object', + expanded: false, + properties: { + array: { + type: 'array', + expanded: false, + items: initArray([1, { type: 'value' }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + } + } + + assert.deepStrictEqual( + ensureRecursiveState({ array: [1, 2, 3] }, undefined, ['array', '1'], documentStateFactory), + expected + ) + }) + + test('should maintain value state', () => { + const state: DocumentState = { + type: 'value', + enforceString: true + } + + assert.deepStrictEqual(ensureRecursiveState(42, state, [], documentStateFactory), state) + }) + }) + + describe('syncDocumentState', () => { + test('should maintain array documentState when no change is needed', () => { + const state: DocumentState = { + type: 'array', + expanded: true, + items: [{ type: 'value', enforceString: false }], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + assert.deepStrictEqual(syncDocumentState([1, 2, 3], state), state) + }) + + test('should maintain object documentState when no change is needed', () => { + const state: DocumentState = { + type: 'object', + expanded: true, + properties: { + c: { type: 'value', enforceString: true } + } + } + assert.deepStrictEqual(syncDocumentState({ a: 1, b: 2, c: 3 }, state), state) + }) + + test('should switch array, object, and value state', () => { + const arrayState = createArrayDocumentState() + const objectState = createObjectDocumentState() + const valueState = createValueDocumentState() + + assert.deepStrictEqual(syncDocumentState(undefined, arrayState), undefined) + assert.deepStrictEqual(syncDocumentState(undefined, objectState), undefined) + assert.deepStrictEqual(syncDocumentState(undefined, valueState), undefined) + assert.deepStrictEqual(syncDocumentState(undefined, undefined), undefined) + + assert.deepStrictEqual(syncDocumentState([], arrayState), arrayState) + assert.deepStrictEqual(syncDocumentState([], objectState), arrayState) + assert.deepStrictEqual(syncDocumentState([], valueState), arrayState) + assert.deepStrictEqual(syncDocumentState([], undefined), undefined) + + assert.deepStrictEqual(syncDocumentState({}, arrayState), objectState) + assert.deepStrictEqual(syncDocumentState({}, objectState), objectState) + assert.deepStrictEqual(syncDocumentState({}, valueState), objectState) + assert.deepStrictEqual(syncDocumentState({}, undefined), undefined) + + assert.deepStrictEqual(syncDocumentState(42, arrayState), undefined) + assert.deepStrictEqual(syncDocumentState(42, objectState), undefined) + assert.deepStrictEqual(syncDocumentState(42, valueState), valueState) + assert.deepStrictEqual(syncDocumentState(42, undefined), undefined) + }) + + test('should maintain expanded state when switching between array and object', () => { + const arrayState = createArrayDocumentState({ expanded: false }) + const objectState = createObjectDocumentState({ expanded: false }) + const arrayStateExpanded = createArrayDocumentState({ expanded: true }) + const objectStateExpanded = createObjectDocumentState({ expanded: true }) + + assert.deepStrictEqual(syncDocumentState({}, arrayState), objectState) + assert.deepStrictEqual(syncDocumentState([], objectState), arrayState) + + assert.deepStrictEqual(syncDocumentState({}, arrayStateExpanded), objectStateExpanded) + assert.deepStrictEqual(syncDocumentState([], objectStateExpanded), arrayStateExpanded) + }) + + test('should remove deleted properties', () => { + const state: DocumentState = { + type: 'object', + expanded: true, + properties: { + a: { type: 'value', enforceString: false }, + b: { type: 'value', enforceString: false }, + c: { type: 'value', enforceString: false } + } + } + + const expected: DocumentState = { + type: 'object', + expanded: true, + properties: { + a: { type: 'value', enforceString: false }, + c: { type: 'value', enforceString: false } + } + } + + assert.deepStrictEqual(syncDocumentState({ a: 1, c: 3, d: 4 }, state), expected) + }) + + test('should remove deleted items', () => { + const state: DocumentState = { + type: 'array', + expanded: true, + items: initArray([1, { type: 'value' }], [2, { type: 'value' }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + const expected: DocumentState = { + type: 'array', + expanded: true, + items: initArray([1, { type: 'value' }]), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual(syncDocumentState([1, 2], state), expected) + }) + + test('should work on nested objects', () => { + const state: DocumentState = { + type: 'object', + expanded: true, + properties: { + nested: { + type: 'object', + expanded: true, + properties: { + c: { type: 'value', enforceString: false }, + d: { type: 'value', enforceString: false } + } + } + } + } + + const expected: DocumentState = { + type: 'object', + expanded: true, + properties: { + nested: { + type: 'object', + expanded: true, + properties: { + c: { type: 'value', enforceString: false } + } + } + } + } + + assert.deepStrictEqual(syncDocumentState({ nested: { a: 1, b: 2, c: 3 } }, state), expected) + + const expected2: DocumentState = { + type: 'object', + expanded: true, + properties: {} + } + + assert.deepStrictEqual(syncDocumentState({ nested: 42 }, state), expected2) + }) + + test('should work on nested arrays', () => { + const state: DocumentState = { + type: 'array', + expanded: true, + items: [ + { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [{ type: 'value' }, { type: 'value' }, { type: 'value' }, { type: 'value' }] + } + ], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + const expected: DocumentState = { + type: 'array', + expanded: true, + items: [ + { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [{ type: 'value' }, { type: 'value' }, { type: 'value' }] + } + ], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual(syncDocumentState([[1, 2, 3]], state), expected) + + const expected2: DocumentState = { + type: 'array', + expanded: true, + items: [], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual(syncDocumentState([42], state), expected2) + }) + + test('objects should be handled in an immutable way', () => { + const json = { + nested: { + c: 2, + d: 3 + } + } + + const state: DocumentState = { + type: 'object', + expanded: true, + properties: { + nested: { + type: 'object', + expanded: true, + properties: { + c: { type: 'value' }, + d: { type: 'value' } + } + } + } + } + + const syncedState = syncDocumentState(json, state) + assert.deepStrictEqual(syncedState, state) + assert.strictEqual(syncedState, state) + }) + + test('arrays should be handled in an immutable way', () => { + const json = [1, 2, 3] + const state: DocumentState = { + type: 'array', + expanded: true, + items: [{ type: 'value' }, { type: 'value' }, { type: 'value' }], + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + const syncedState = syncDocumentState(json, state) + assert.deepStrictEqual(syncedState, state) + assert.strictEqual(syncedState, state) + }) + + test('should handle a change in an immutable way', () => { + const items: DocumentState[] = [{ type: 'value' }, { type: 'value' }, { type: 'value' }] + const state: ArrayDocumentState = { + type: 'array', + expanded: true, + items, + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + const updatedJson = [1, 2] + + const syncedState = syncDocumentState(updatedJson, state) as ArrayDocumentState + + const expected: DocumentState = { + type: 'array', + expanded: true, + items: items.slice(0, 2), + visibleSections: DEFAULT_VISIBLE_SECTIONS + } + + assert.deepStrictEqual(syncedState, expected) + assert.strictEqual(syncedState.items[0], expected.items[0]) + assert.strictEqual(syncedState.items[1], expected.items[1]) + }) + }) + + test('toRecursiveStatePath', () => { + const json = { + foo: { a: 42 }, + bar: [1, 2, 3] + } + + expect(toRecursiveStatePath(json, [])).toEqual([]) + expect(toRecursiveStatePath(json, ['foo'])).toEqual(['properties', 'foo']) + expect(toRecursiveStatePath(json, ['bar'])).toEqual(['properties', 'bar']) + expect(toRecursiveStatePath(json, ['bar', '2'])).toEqual(['properties', 'bar', 'items', '2']) + }) + + test('getInRecursiveState', () => { + const json = { + foo: { a: 42 }, + bar: [1, 2, 3] + } + const state = createDocumentState({ json, expand: () => true }) + + expect(getInRecursiveState(json, state, [])).toEqual(state) + expect(getInRecursiveState(json, state, ['foo'])).toEqual( + (state as ObjectDocumentState).properties.foo + ) + expect(getInRecursiveState(json, state, ['bar'])).toEqual( + (state as ObjectDocumentState).properties.bar + ) + expect(getInRecursiveState(json, state, ['bar', '2'])).toEqual( + ((state as ObjectDocumentState).properties.bar as ArrayDocumentState).items[2] + ) + + expect(getInRecursiveState(json, state, ['non', 'existing'])).toEqual(undefined) + }) + + describe('expandPath with callback', () => { const json = { array: [1, 2, { c: 6 }], object: { a: 4, b: 5 }, value: 'hello' } - const state = createDocumentState() + const documentState = createDocumentState({ json }) test('should fully expand a json document', () => { - assert.deepStrictEqual(expandWithCallback(json, state, [], () => true).expandedMap, { - '': true, - '/array': true, - '/array/2': true, - '/object': true - }) + assert.deepStrictEqual( + expandPath(json, documentState, [], () => true), + { + type: 'object', + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: initArray([2, { type: 'object', expanded: true, properties: {} }]) + }, + object: { + type: 'object', + expanded: true, + properties: {} + } + } + } + ) }) test('should expand a nested item of a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, state, ['array'], (path) => isEqual(path, ['array'])).expandedMap, + expandPath(json, documentState, ['array'], (relativePath) => isEqual(relativePath, [])), { - '/array': true + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [] + } + }, + type: 'object' } ) }) - test('should expand a part of a json document recursively', () => { - assert.deepStrictEqual(expandWithCallback(json, state, ['array'], () => true).expandedMap, { - '/array': true, - '/array/2': true - }) + test('should expand a nested item of a json document starting without state', () => { + assert.deepStrictEqual( + expandPath(json, undefined, ['array'], (relativePath) => isEqual(relativePath, [])), + { + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [] + } + }, + type: 'object' + } + ) }) - test('should partially expand a json document', () => { + test('should expand a part of a json document recursively', () => { assert.deepStrictEqual( - expandWithCallback(json, state, [], (path) => path.length <= 1).expandedMap, + expandPath(json, documentState, ['array'], () => true), { - '': true, - '/array': true, - '/object': true + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: initArray([2, { expanded: true, properties: {}, type: 'object' }]) + } + }, + type: 'object' } ) }) - test('should expand the root of a json document', () => { + test('should partially expand a json document', () => { assert.deepStrictEqual( - expandWithCallback(json, state, [], (path) => path.length === 0).expandedMap, + expandPath(json, documentState, [], (relativePath) => relativePath.length <= 1), { - '': true + expanded: true, + properties: { + array: { + type: 'array', + expanded: true, + visibleSections: DEFAULT_VISIBLE_SECTIONS, + items: [] + }, + object: { type: 'object', expanded: true, properties: {} } + }, + type: 'object' } ) }) - test('should not traverse non-expanded nodes', () => { + 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, state, [], (path) => path.length > 0).expandedMap, - {} + expandPath(json, documentState, [], (relativePath) => relativePath.length === 0), + { + expanded: true, + properties: {}, + type: 'object' + } ) }) - test('should leave already expanded nodes as is', () => { - const expandedMap = { - '': true, - '/array': true - } - const stateWithExpanded = { - ...state, - expandedMap - } + 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, stateWithExpanded, [], () => false).expandedMap, - expandedMap + expandPath(json, documentState, [], (relativePath) => relativePath.length > 0), + { + expanded: false, + properties: {}, + type: 'object' + } ) }) }) @@ -121,7 +635,7 @@ describe('documentState', () => { value: 'hello' } - const documentState = createDocumentState() + const documentState = createDocumentState({ json }) assert.deepStrictEqual(getVisiblePaths(json, documentState), [[]]) const documentState0 = createDocumentState({ json, expand: (path) => path.length <= 0 }) @@ -167,7 +681,7 @@ describe('documentState', () => { } // by default, should have a visible section from 0-100 only (so 100-500 is invisible) - const documentState1 = createDocumentState({ json, expand: (path) => path.length <= 1 }) + const documentState1 = createDocumentState({ json, expand: () => true }) assert.deepStrictEqual(getVisiblePaths(json, documentState1), [ [], ['array'], @@ -177,10 +691,7 @@ describe('documentState', () => { // create a visible section from 200-300 (in addition to the visible section 0-100) const start = 2 * ARRAY_SECTION_SIZE const end = 3 * ARRAY_SECTION_SIZE - const documentState2 = expandSection(json, documentState1, compileJSONPointer(['array']), { - start, - end - }) + const documentState2 = expandSection(json, documentState1, ['array'], { start, end }) assert.deepStrictEqual(getVisiblePaths(json, documentState2), [ [], ['array'], @@ -320,10 +831,7 @@ describe('documentState', () => { // create a visible section from 200-300 (in addition to the visible section 0-100) const start = 2 * ARRAY_SECTION_SIZE const end = 3 * ARRAY_SECTION_SIZE - const documentState2 = expandSection(json, documentState1, compileJSONPointer(['array']), { - start, - end - }) + const documentState2 = expandSection(json, documentState1, ['array'], { start, end }) assert.deepStrictEqual( getVisibleCaretPositions(json, documentState2), flatMap([ @@ -352,24 +860,40 @@ describe('documentState', () => { ) }) - test('should keep/update enforce string', () => { + test('should determine enforce string', () => { const json1 = 42 - const documentState1 = createDocumentState() - assert.strictEqual( - getEnforceString(json1, documentState1.enforceStringMap, compileJSONPointer([]), JSON), - false - ) + const documentState1 = createDocumentState({ json: json1 }) + assert.strictEqual(getEnforceString(json1, documentState1, []), false) const json2 = '42' - const documentState2 = createDocumentState() - assert.strictEqual( - getEnforceString(json2, documentState2.enforceStringMap, compileJSONPointer([]), JSON), - true - ) + const documentState2 = createDocumentState({ json: json2 }) + assert.strictEqual(getEnforceString(json2, documentState2, []), true) + + const json3 = 'true' + const documentState3 = createDocumentState({ json: json3 }) + assert.strictEqual(getEnforceString(json3, documentState3, []), true) + + const json4 = 'null' + const documentState4 = createDocumentState({ json: json4 }) + assert.strictEqual(getEnforceString(json4, documentState4, []), true) + }) + + test('should create enforce string state if needed', () => { + const json = '42' + const documentState = createDocumentState({ json }) + assert.strictEqual(getEnforceString(json, documentState, []), true) + assert.strictEqual((documentState as ValueDocumentState).enforceString, undefined) + + const result = documentStatePatch(json, documentState, [ + { op: 'replace', path: '', value: 'abc' } + ]) + assert.strictEqual(result.json, 'abc') + assert.strictEqual(getEnforceString(result.json, result.documentState, []), true) + assert.strictEqual((result.documentState as ValueDocumentState).enforceString, true) }) describe('documentStatePatch', () => { - function createJsonAndState(): { json: unknown; documentState: DocumentState } { + function createJsonAndState(): { json: unknown; documentState: DocumentState | undefined } { const json = { members: [ { id: 1, name: 'Joe' }, @@ -385,19 +909,20 @@ describe('documentState', () => { } } - const documentState: DocumentState = { - ...createDocumentState({ json, expand: () => true }), - visibleSectionsMap: { - '/members': [{ start: 0, end: 3 }] - } - } + let documentState = createDocumentState({ json, expand: () => true }) + + documentState = updateInDocumentState(json, documentState, ['members'], (_value, state) => { + return isArrayRecursiveState(state) + ? { ...state, visibleSections: [{ start: 0, end: 3 }] } + : state + }) return { json, documentState } } test('add: should add a value to an object', () => { const json = { a: 2, b: 3 } - const documentState = createDocumentState() + const documentState = createDocumentState({ json }) const res = documentStatePatch(json, documentState, [{ op: 'add', path: '/c', value: 4 }]) @@ -407,12 +932,7 @@ describe('documentState', () => { test('add: should add a value to an object (expanded)', () => { const json = { a: 2, b: 3 } - const documentState: DocumentState = { - ...createDocumentState(), - expandedMap: { - '': true - } - } + const documentState = createDocumentState({ json, expand: () => true }) const res = documentStatePatch(json, documentState, [{ op: 'add', path: '/c', value: 42 }]) @@ -422,12 +942,7 @@ describe('documentState', () => { test('add: should override a value in an object', () => { const json = { a: 2, b: 3 } - const documentState: DocumentState = { - ...createDocumentState(), - expandedMap: { - '': true - } - } + const documentState = createDocumentState({ json, expand: () => true }) const res = documentStatePatch(json, documentState, [{ op: 'add', path: '/a', value: 42 }]) @@ -452,19 +967,28 @@ describe('documentState', () => { ]) assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/group/details': true, - '/members': true, - '/members/0': true, - '/members/2': true, - '/members/3': true + expanded: true, + properties: { + group: { + expanded: true, + properties: { + details: { expanded: true, properties: {}, type: 'object' } + }, + type: 'object' + }, + members: { + expanded: true, + items: [ + { expanded: true, properties: {}, type: 'object' }, + undefined, // ideally, this should be an empty item, not undefined + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' } + ], + type: 'array', + visibleSections: [{ start: 0, end: 4 }] + } }, - visibleSectionsMap: { - '/members': [{ start: 0, end: 4 }] - } + type: 'object' }) }) @@ -486,46 +1010,36 @@ describe('documentState', () => { test('add: extend the visibleSection when appending a value to an array', () => { const json = [0, 1, 2, 3] - const documentState = { - ...createDocumentState(), - expandedMap: { - '': true - }, - visibleSectionsMap: { - '': [{ start: 0, end: 5 }] - } + const documentState: DocumentState = { + type: 'array', + expanded: true, + items: [], + visibleSections: [{ start: 0, end: 5 }] } const res = documentStatePatch(json, documentState, [{ op: 'add', path: '/4', value: 4 }]) assert.deepStrictEqual(res.documentState, { - ...documentState, - visibleSectionsMap: { - '': [{ start: 0, end: 6 }] - } + type: 'array', + expanded: true, + items: [], + visibleSections: [{ start: 0, end: 6 }] }) }) test('replace: should keep enforceString state', () => { const json = '42' - const documentState = { - ...createDocumentState(), - enforceStringMap: { - '': true - } + const documentState: DocumentState = { + type: 'value', + enforceString: true } - const pointer = compileJSONPointer([]) - assert.strictEqual( - getEnforceString(json, documentState.enforceStringMap, pointer, JSON), - true - ) const operations: JSONPatchDocument = [{ op: 'replace', path: '', value: 'forty two' }] const res = documentStatePatch(json, documentState, operations) - assert.deepStrictEqual( - getEnforceString(res.json, res.documentState.enforceStringMap, pointer, JSON), - true - ) + assert.deepStrictEqual(res.documentState, { + type: 'value', + enforceString: true + }) }) test('remove: should remove a value from an object', () => { @@ -544,30 +1058,36 @@ describe('documentState', () => { const res = documentStatePatch(json, documentState, [{ op: 'remove', path: '/members/1' }]) assert.deepStrictEqual(res.json, deleteIn(json, ['members', '1'])) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: deleteIn(documentState.expandedMap, ['/members/2']), // [2] is moved to [1] - visibleSectionsMap: { - '/members': [{ start: 0, end: 2 }] - } - }) + assert.deepStrictEqual( + res.documentState, + updateIn(documentState, ['properties', 'members'], (state: DocumentState) => ({ + ...state, + items: state.type === 'array' ? state.items.slice(0, 2) : undefined, + visibleSections: [{ start: 0, end: 2 }] + })) + ) }) test('remove: should remove a value from an array (2)', () => { // verify that the maps indices are shifted const { json, documentState } = createJsonAndState() - const documentState2 = collapsePath(documentState, ['members', '1']) + const documentState2 = setIn( + documentState, + ['properties', 'members', 'items', '1', 'expanded'], + false + ) const res = documentStatePatch(json, documentState2, [{ op: 'remove', path: '/members/1' }]) assert.deepStrictEqual(res.json, deleteIn(json, ['members', '1'])) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: deleteIn(documentState.expandedMap, ['/members/2']), // [2] is moved to [1] - visibleSectionsMap: { - '/members': [{ start: 0, end: 2 }] - } - }) + assert.deepStrictEqual( + res.documentState, + updateIn(documentState, ['properties', 'members'], (state: DocumentState) => ({ + ...state, + items: state.type === 'array' ? [state.items[0], state.items[2]] : undefined, + visibleSections: [{ start: 0, end: 2 }] + })) + ) }) test('replace: should replace a an object with a value', () => { @@ -578,16 +1098,7 @@ describe('documentState', () => { ]) assert.deepStrictEqual(res.json, setIn(json, ['group'], 42)) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/members': true, - '/members/0': true, - '/members/1': true, - '/members/2': true - } - }) + assert.deepStrictEqual(res.documentState, deleteIn(documentState, ['properties', 'group'])) }) test('replace: should replace a an object with a new object', () => { @@ -599,14 +1110,20 @@ describe('documentState', () => { assert.deepStrictEqual(res.json, setIn(json, ['group'], { groupId: '1234' })) assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/members': true, - '/members/0': true, - '/members/1': true, - '/members/2': true + type: 'object', + expanded: true, + properties: { + group: { expanded: true, properties: {}, type: 'object' }, + members: { + type: 'array', + expanded: true, + items: [ + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' }, + { expanded: true, properties: {}, type: 'object' } + ], + visibleSections: [{ end: 3, start: 0 }] + } } }) }) @@ -618,11 +1135,16 @@ describe('documentState', () => { { op: 'replace', path: '/members/1', value: 42 } ]) + const items = getIn(documentState, ['properties', 'members', 'items']) as DocumentState[] assert.deepStrictEqual(res.json, setIn(json, ['members', '1'], 42)) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: deleteIn(documentState.expandedMap, ['/members/1']) - }) + assert.deepStrictEqual( + res.documentState, + setIn( + documentState, + ['properties', 'members', 'items'], + initArray([0, items[0]], [2, items[2]]) + ) + ) }) test('replace: should replace an array with a value', () => { @@ -633,36 +1155,22 @@ describe('documentState', () => { ]) assert.deepStrictEqual(res.json, setIn(json, ['members'], 42)) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/group/details': true - }, - visibleSectionsMap: {} - }) + assert.deepStrictEqual(res.documentState, deleteIn(documentState, ['properties', 'members'])) }) test('replace: should replace the root document itself', () => { const json = { - c: { - cc: 4 - }, - b: { - bb: 3 - }, - a: 2 - } - const documentState = { - ...createDocumentState({ json, expand: () => true }) + c: { cc: 4 }, + b: { bb: 3 }, + a: { aa: 222 } } + const documentState = createDocumentState({ json, expand: () => true }) const operations: JSONPatchDocument = [ { op: 'replace', path: '', - value: { a: 22, b: 33, d: 55 } + value: { a: { aa: 22 }, b: 33, d: 55 } } ] const res = documentStatePatch(json, documentState, operations) @@ -670,12 +1178,14 @@ describe('documentState', () => { // check order of keys assert.deepStrictEqual(Object.keys(res.json as Record), ['a', 'b', 'd']) - // keep expanded state of existing keys - assert.strictEqual(res.documentState.expandedMap[compileJSONPointer([])], true) - assert.strictEqual(res.documentState.expandedMap[compileJSONPointer(['b'])], true) - - // remove expanded state of removed keys - assert.strictEqual(res.documentState.expandedMap[compileJSONPointer(['c'])], undefined) + // keep expanded state of existing keys, and remove expanded state of removed keys + assert.deepStrictEqual(res.documentState, { + type: 'object', + expanded: true, + properties: { + a: { type: 'object', expanded: true, properties: {} } + } + }) }) test('copy: should copy a value into an object', () => { @@ -687,48 +1197,56 @@ describe('documentState', () => { assert.deepStrictEqual( res.json, - setIn( - json, - ['group', 'user'], - ((json as Record)['members'] as Array)[1] - ) + setIn(json, ['group', 'user'], getIn(json, ['members', '1'])) + ) + assert.deepStrictEqual( + res.documentState, + setIn(documentState, ['properties', 'group', 'properties', 'user'], { + type: 'object', + expanded: true, + properties: {} + }) ) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - ...documentState.expandedMap, - '/group/user': true - } - }) }) test('copy: should copy a value into an array', () => { const { json, documentState } = createJsonAndState() + const documentState2 = setIn( + documentState, + ['properties', 'group', 'properties', 'details', 'expanded'], + false + ) - const res = documentStatePatch(json, documentState, [ + const res = documentStatePatch(json, documentState2, [ { op: 'copy', from: '/group/details', path: '/members/1' } ]) assert.deepStrictEqual(res.json, { - group: (json as Record)['group'], + group: getIn(json, ['group']), members: [ - ((json as Record)['members'] as Array)[0], - ((json as Record)['group'] as Record)['details'], - ((json as Record)['members'] as Array)[1], - ((json as Record)['members'] as Array)[2] + getIn(json, ['members', '0']), + getIn(json, ['group', 'details']), + getIn(json, ['members', '1']), + getIn(json, ['members', '2']) ] }) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - ...documentState.expandedMap, - '/members/3': true - }, - visibleSectionsMap: { - '/members': [{ start: 0, end: 4 }] - } - }) + assert.deepStrictEqual( + res.documentState, + updateIn(documentState2, ['properties', 'members'], (state: DocumentState) => ({ + ...state, + items: + state.type === 'array' + ? [ + state.items[0], + getIn(documentState2, ['properties', 'group', 'properties', 'details']), + state.items[1], + state.items[2] + ] + : undefined, + visibleSections: [{ start: 0, end: 4 }] + })) + ) }) test('move: should move a value from object to object', () => { @@ -748,18 +1266,14 @@ describe('documentState', () => { description: 'The first group' } }) - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/details': true, - '/members': true, - '/members/0': true, - '/members/1': true, - '/members/2': true - } - }) + + let expectedDocumentState = documentState + const fromPathRecursive = ['properties', 'group', 'properties', 'details'] + const value = getIn(expectedDocumentState, fromPathRecursive) + expectedDocumentState = deleteIn(expectedDocumentState, fromPathRecursive) + expectedDocumentState = setIn(expectedDocumentState, ['properties', 'details'], value) + + assert.deepStrictEqual(res.documentState, expectedDocumentState) }) test('move: moving a value inside the object itself should move it to the end of keys', () => { @@ -777,7 +1291,11 @@ describe('documentState', () => { // we collapse the member we're going to move, so we can see whether the state is correctly switched const jsonAndState = createJsonAndState() const json = jsonAndState.json - const documentState = collapsePath(jsonAndState.documentState, ['members', '1']) + const documentState = setIn( + jsonAndState.documentState, + ['properties', 'members', 'items', '1', 'expanded'], + false + ) const res = documentStatePatch(json, documentState, [ { op: 'move', from: '/members/1', path: '/members/0' } @@ -786,31 +1304,34 @@ describe('documentState', () => { assert.deepStrictEqual(res.json, { group: (json as Record)['group'], members: [ - ((json as Record)['members'] as Array)[1], - ((json as Record)['members'] as Array)[0], - ((json as Record)['members'] as Array)[2] + getIn(json, ['members', '1']), + getIn(json, ['members', '0']), + getIn(json, ['members', '2']) ] }) // we have collapsed members[1], and after that moved it from index 1 to 0, so now members[0] should be collapsed - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/group/details': true, - '/members': true, - '/members/1': true, - '/members/2': true - } - }) + assert.deepStrictEqual( + res.documentState, + updateIn(documentState, ['properties', 'members'], (state: DocumentState | undefined) => { + return { + ...state, + items: + state?.type === 'array' ? [state.items[1], state.items[0], state.items[2]] : undefined + } + }) + ) }) test('move: should move a value from array to array (down)', () => { // we collapse the member we're going to move, so we can see whether the state is correctly switched const jsonAndState = createJsonAndState() const json = jsonAndState.json - const documentState = collapsePath(jsonAndState.documentState, ['members', '0']) + const documentState = setIn( + jsonAndState.documentState, + ['properties', 'members', 'items', '0', 'expanded'], + false + ) const res = documentStatePatch(json, documentState, [ { op: 'move', from: '/members/0', path: '/members/1' } @@ -819,30 +1340,33 @@ describe('documentState', () => { assert.deepStrictEqual(res.json, { group: (json as Record)['group'], members: [ - ((json as Record)['members'] as Array)[1], - ((json as Record)['members'] as Array)[0], - ((json as Record)['members'] as Array)[2] + getIn(json, ['members', '1']), + getIn(json, ['members', '0']), + getIn(json, ['members', '2']) ] }) // we have collapsed members[0], and after that moved it from index 0 to 1, so now members[1] should be collapsed - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/group/details': true, - '/members': true, - '/members/0': true, - '/members/2': true - } - }) + assert.deepStrictEqual( + res.documentState, + updateIn(documentState, ['properties', 'members'], (state: DocumentState | undefined) => { + return { + ...state, + items: + state?.type === 'array' ? [state.items[1], state.items[0], state.items[2]] : undefined + } + }) + ) }) test('move: should move a value from object to array', () => { const jsonAndState = createJsonAndState() const json = jsonAndState.json - const documentState = collapsePath(jsonAndState.documentState, ['members', '1']) + const documentState = setIn( + jsonAndState.documentState, + ['properties', 'members', 'items', '1', 'expanded'], + false + ) const res = documentStatePatch(json, documentState, [ { op: 'move', from: '/group/details', path: '/members/1' } @@ -854,30 +1378,33 @@ describe('documentState', () => { location: 'Block C' }, members: [ - ((json as Record)['members'] as Array)[0], - { - description: 'The first group' - }, - ((json as Record)['members'] as Array)[1], - ((json as Record)['members'] as Array)[2] + getIn(json, ['members', '0']), + { description: 'The first group' }, + getIn(json, ['members', '1']), + getIn(json, ['members', '2']) ] }) - // we have collapsed members[0], and after that moved it from index 0 to 1, so now members[1] should be collapsed - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/members': true, - '/members/0': true, - '/members/1': true, - '/members/3': true - }, - visibleSectionsMap: { - '/members': [{ start: 0, end: 4 }] - } - }) + const fromPathRecursive = ['properties', 'group', 'properties', 'details'] + let expectedDocumentState = deleteIn(documentState, fromPathRecursive) + expectedDocumentState = updateIn( + expectedDocumentState, + ['properties', 'members'], + (state: DocumentState) => ({ + ...state, + items: + state.type === 'array' + ? [ + state.items[0], + getIn(documentState, fromPathRecursive), + state.items[1], + state.items[2] + ] + : undefined, + visibleSections: [{ start: 0, end: 4 }] + }) + ) + assert.deepStrictEqual(res.documentState, expectedDocumentState) }) test('move: should move a value from array to object', () => { @@ -889,34 +1416,71 @@ describe('documentState', () => { assert.deepStrictEqual(res.json, { group: { - ...((json as Record)['group'] as Record), - user: ((json as Record)['members'] as Array)[1] + ...(getIn(json, ['group']) as Record), + user: getIn(json, ['members', '1']) }, - members: [ - ((json as Record)['members'] as Array)[0], - ((json as Record)['members'] as Array)[2] - ] + members: [getIn(json, ['members', '0']), getIn(json, ['members', '2'])] }) - // we have collapsed members[0], and after that moved it from index 0 to 1, so now members[1] should be collapsed - assert.deepStrictEqual(res.documentState, { - ...documentState, - expandedMap: { - '': true, - '/group': true, - '/group/details': true, - '/group/user': true, - '/members': true, - '/members/0': true, - '/members/1': true - }, - visibleSectionsMap: { - '/members': [{ start: 0, end: 2 }] - } + const pathRecursive = ['properties', 'group', 'properties', 'user'] + let expectedDocumentState = setIn(documentState, pathRecursive, { + type: 'object', + expanded: true, + properties: {} }) + expectedDocumentState = updateIn( + expectedDocumentState, + ['properties', 'members'], + (state: DocumentState) => ({ + ...state, + items: state.type === 'array' ? [state.items[0], state.items[2]] : undefined, + visibleSections: [{ start: 0, end: 2 }] + }) + ) + assert.deepStrictEqual(res.documentState, expectedDocumentState) + }) + }) + + test('move: should extract an array item', () => { + const res = documentStatePatch(json3, documentState3, [{ op: 'move', from: '/1', path: '' }]) + + assert.deepStrictEqual(res.json, { id: 1 }) + assert.deepStrictEqual(res.documentState, { + type: 'object', + expanded: false, + properties: { + id: { type: 'value', enforceString: true } + } }) }) + test('move: should handle multiple operations', () => { + const res = documentStatePatch(json3, documentState3, [ + { op: 'move', from: '/1', path: '' }, + { op: 'move', from: '/id', path: '/identifier' } + ]) + + assert.deepStrictEqual(res.json, { identifier: 1 }) + assert.deepStrictEqual(res.documentState, { + type: 'object', + expanded: false, + properties: { + identifier: { type: 'value', enforceString: true } + } + }) + }) + + test('move: should extract an array item', () => { + const json = [{ id: 0 }, { id: 1 }, { id: 2 }] + const documentState = createDocumentState({ json, expand: () => true }) + + const res = documentStatePatch(json, documentState, [{ op: 'move', from: '/1', path: '' }]) + + assert.deepStrictEqual(res.json, { id: 1 }) + + assert.deepStrictEqual(res.documentState, { type: 'object', expanded: true, properties: {} }) + }) + describe('shiftVisibleSections', () => { const json = [1, 2, 3, 4, 5, 6, 7, 8] const visibleSections: VisibleSection[] = [ @@ -996,7 +1560,7 @@ describe('documentState', () => { }) }) - describe('expandPath', () => { + describe('expandPath without callback', () => { const json = { array: [1, 2, { c: 6 }], object: { a: 4, b: 5, nested: { c: 6 } }, @@ -1004,174 +1568,376 @@ describe('documentState', () => { } test('should expand root path', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState(), []).expandedMap, {}) + assert.deepStrictEqual(expandPath(json, createDocumentState({ json }), [], expandSelf), { + type: 'object', + expanded: true, + properties: {} + }) }) test('should expand an array', () => { - assert.deepStrictEqual(expandPath(json, createDocumentState(), ['array']).expandedMap, { - '': true - }) + 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(), ['array', '2']).expandedMap, { - '': true, - '/array': true - }) + 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(), ['array', '0']).expandedMap, { - '': true, - '/array': true - }) + 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(), ['object']).expandedMap, { - '': true - }) + 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(), ['object', 'nested']).expandedMap, + expandPath(json, createDocumentState({ json }), ['object', 'nested'], expandNone), { - '': true, - '/object': true + type: 'object', + expanded: true, + properties: { + object: { + type: 'object', + expanded: true, + properties: { + nested: { expanded: false, properties: {}, type: 'object' } + } + } + } } ) }) 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(), ['largeArray', '120']), { - ...createDocumentState(), - expandedMap: { - '': true, - '/largeArray': true - }, - visibleSectionsMap: { - '/largeArray': [{ start: 0, end: 200 }] + assert.deepStrictEqual( + expandPath(json, createDocumentState({ json }), ['largeArray', '120'], expandNone), + { + type: 'object', + expanded: true, + properties: { + largeArray: { + type: 'array', + expanded: true, + 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('shiftPath', () => { - const expandedPaths: JSONPointerMap = { - '/array': 1, - '/array/0': 2, - '/array/0/name': 3, - '/array/2': 4, - '/array/2/name': 5, - '/array/3': 6, - '/array/3/name': 7, - '/obj': 8 - } + 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 shift entries one up', () => { - deepStrictEqual(shiftPath(expandedPaths, ['array'], 2, -1), { - '/array': 1, - '/array/0': 2, - '/array/0/name': 3, - '/array/1': 4, - '/array/1/name': 5, - '/array/2': 6, - '/array/2/name': 7, - '/obj': 8 + 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 shift entries one down', () => { - deepStrictEqual(shiftPath(expandedPaths, ['array'], 2, 1), { - '/array': 1, - '/array/0': 2, - '/array/0/name': 3, - '/array/3': 4, - '/array/3/name': 5, - '/array/4': 6, - '/array/4/name': 7, - '/obj': 8 + test('should expand only the object root of a large document', () => { + assert.deepStrictEqual(expandSmart(object, undefined, [], 10), { + expanded: true, + properties: {}, + type: 'object' }) }) - test('should shift entries an offset 0 (do nothing)', () => { - deepStrictEqual(shiftPath(expandedPaths, ['array'], 2, 0), expandedPaths) + 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('deletePath', () => { - const myStateMap: JSONPointerMap = { - '/array': 1, - '/array/0': 2, - '/array/1': 3, - '/array/2': 4, - '/array/2/name': 5, - '/obj': 6 + 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('should delete an object path from a PathsMap', () => { - deepStrictEqual(deletePath(myStateMap, ['obj']), { - '/array': 1, - '/array/0': 2, - '/array/1': 3, - '/array/2': 4, - '/array/2/name': 5 + 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('should delete an array item from a PathsMap', () => { - deepStrictEqual(deletePath(myStateMap, ['array', '1']), { - '/array': 1, - '/array/0': 2, - '/array/2': 4, - '/array/2/name': 5, - '/obj': 6 + 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('should delete nested paths from a PathsMap', () => { - deepStrictEqual(deletePath(myStateMap, ['array']), { - '/obj': 6 + 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 }] + } + } }) }) - }) - describe('filterPath', () => { - const expandedPaths: JSONPointerMap = { - '/array': 1, - '/array/0': 2, - '/array/1': 3, - '/array/2': 4, - '/array/2/name': 5, - '/obj': 6 - } + test('collapse should do nothing on a non-existing path (1)', () => { + const nonExpandedState: DocumentState = { + type: 'object', + expanded: false, + properties: {} + } - test('should filter an object path from a PathsMap', () => { - deepStrictEqual(filterPath(expandedPaths, '/obj'), { - '/obj': 6 + assert.deepStrictEqual(collapsePath(json, nonExpandedState, [], false), { + type: 'object', + expanded: false, + properties: {} }) }) - test('should filter an array item from a PathsMap', () => { - deepStrictEqual(filterPath(expandedPaths, '/array/1'), { - '/array/1': 3 + 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 + } + } }) }) + }) - test('should delete nested paths from a PathsMap', () => { - deepStrictEqual(filterPath(expandedPaths, '/array'), { - '/array': 1, - '/array/0': 2, - '/array/1': 3, - '/array/2': 4, - '/array/2/name': 5 + describe('deleteInDocumentState', () => { + const json = { value: '42' } + const documentState: DocumentState = { + type: 'object', + expanded: true, + properties: { + value: { type: 'value', enforceString: true } + } + } + + test('delete existing state', () => { + expect(deleteInDocumentState(json, documentState, ['value'])).toEqual({ + type: 'object', + expanded: true, + properties: {} }) }) + + test('delete non-existing state', () => { + expect(deleteInDocumentState(json, documentState, ['foo'])).toEqual(documentState) + }) }) }) @@ -1189,3 +1955,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 26394356..b1b06f74 100644 --- a/src/lib/logic/documentState.ts +++ b/src/lib/logic/documentState.ts @@ -1,5 +1,6 @@ import { compileJSONPointer, + deleteIn, existsIn, getIn, immutableJSONPatch, @@ -14,18 +15,17 @@ import { type JSONPatchCopy, type JSONPatchDocument, type JSONPatchMove, + type JSONPatchOperation, type JSONPatchRemove, - type JSONPatchReplace, type JSONPath, - type JSONPointer, - parseJSONPointer, parsePath, - startsWithJSONPointer + setIn, + updateIn } from 'immutable-json-patch' -import { initial, isEqual, last } from 'lodash-es' +import { initial, last } from 'lodash-es' import { DEFAULT_VISIBLE_SECTIONS, MAX_DOCUMENT_SIZE_EXPAND_ALL } from '../constants.js' -import { forEachIndex } from '../utils/arrayUtils.js' -import { isObject, isObjectOrArray, isStringContainingPrimitiveValue } from '../utils/typeUtils.js' +import { forEachIndex, insertItemsAt, strictShallowEqual } from '../utils/arrayUtils.js' +import { isObject, isStringContainingPrimitiveValue } from '../utils/typeUtils.js' import { currentRoundNumber, inVisibleSection, @@ -33,55 +33,222 @@ import { nextRoundNumber } from './expandItemsSections.js' import type { + ArrayDocumentState, CaretPosition, DocumentState, - JSONParser, - JSONPointerMap, - JSONSelection, + ObjectDocumentState, OnExpand, + ArrayRecursiveState, + RecursiveState, + RecursiveStateFactory, Section, + ValueDocumentState, VisibleSection } from '$lib/types' import { CaretType } from '$lib/types.js' import { int } from '../utils/numberUtils.js' import { isLargeContent } from '$lib/utils/jsonUtils.js' +import { + isArrayRecursiveState, + isExpandableState, + isObjectRecursiveState, + isValueRecursiveState +} from '$lib/typeguards.js' -type OnCreateSelection = (json: unknown, documentState: DocumentState) => JSONSelection +export type CreateRecursiveStateProps = { + json: unknown | undefined + factory: RecursiveStateFactory +} + +export function createRecursiveState({ + json, + factory +}: CreateRecursiveStateProps): RecursiveState | undefined { + return Array.isArray(json) + ? factory.createArrayDocumentState() + : isObject(json) + ? factory.createObjectDocumentState() + : json !== undefined + ? factory.createValueDocumentState() + : undefined +} export type CreateDocumentStateProps = { json: unknown | undefined expand?: OnExpand - select?: OnCreateSelection } -export function createDocumentState(props?: CreateDocumentStateProps): DocumentState { - let documentState: DocumentState = { - expandedMap: {}, - enforceStringMap: {}, - visibleSectionsMap: {}, - selection: null, - sortedColumn: null +export function createDocumentState({ + json, + expand +}: CreateDocumentStateProps): DocumentState | undefined { + let documentState: DocumentState | undefined = createRecursiveState({ + json, + factory: documentStateFactory + }) as DocumentState + + if (expand && documentState) { + documentState = expandPath(json, documentState, [], expand) } - if (props?.select && props.json !== undefined) { - documentState = { - ...documentState, - selection: props.select(props.json, documentState) + return documentState +} + +export function createArrayDocumentState({ expanded } = { expanded: false }): ArrayDocumentState { + return { type: 'array', expanded, visibleSections: DEFAULT_VISIBLE_SECTIONS, items: [] } +} + +export function createObjectDocumentState({ expanded } = { expanded: false }): ObjectDocumentState { + return { type: 'object', expanded, properties: {} } +} + +export function createValueDocumentState(): ValueDocumentState { + return { type: 'value' } +} + +export const documentStateFactory: RecursiveStateFactory = { + createObjectDocumentState, + createArrayDocumentState, + createValueDocumentState +} + +export function ensureRecursiveState( + json: unknown, + documentState: T | undefined, + path: JSONPath, + { + createObjectDocumentState, + createArrayDocumentState, + createValueDocumentState + }: RecursiveStateFactory +): T { + function recurse(value: unknown, state: T | undefined, path: JSONPath): T { + if (Array.isArray(value)) { + const arrayState: ArrayRecursiveState = isArrayRecursiveState(state) + ? state + : createArrayDocumentState() + if (path.length === 0) { + return arrayState as T + } + + const index = int(path[0]) + const itemState = recurse(value[index], arrayState.items[index] as T, path.slice(1)) + return setIn(arrayState, ['items', path[0]], itemState) } - } - if (props?.expand) { - documentState = expandWithCallback(props.json, documentState, [], props.expand) + if (isObject(value)) { + const objectState = isObjectRecursiveState(state) ? state : createObjectDocumentState() + if (path.length === 0) { + return objectState as T + } + + const key = path[0] + const itemState = recurse(value[key], objectState.properties[key] as T, path.slice(1)) + return setIn(objectState, ['properties', key], itemState) + } + + return isValueRecursiveState(state) ? state : (createValueDocumentState() as T) } - return documentState + return recurse(json, documentState, path) } -export function getVisibleSections( - documentState: DocumentState, - pointer: JSONPointer -): VisibleSection[] { - return documentState.visibleSectionsMap[pointer] || DEFAULT_VISIBLE_SECTIONS +export function syncDocumentState( + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath = [] +): DocumentState | undefined { + return _transformDocumentState( + json, + documentState, + path, + (nestedJson, nestedState) => { + if (nestedJson === undefined || nestedState === undefined) { + return undefined + } + + if (Array.isArray(nestedJson)) { + if (isArrayRecursiveState(nestedState)) { + return nestedState + } + + const expanded = isExpandableState(nestedState) ? nestedState.expanded : false + return createArrayDocumentState({ expanded }) + } + + if (isObject(nestedJson)) { + if (isObjectRecursiveState(nestedState)) { + return nestedState + } + + const expanded = isExpandableState(nestedState) ? nestedState.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(json).forEach((key) => { + const propPath = path.concat(key) + const value = json[key] + 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(updatedState.properties) + ) + + return changed ? { ...updatedState, properties } : updatedState + } + + return updatedState } /** @@ -97,162 +264,151 @@ 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, - path: JSONPath -): DocumentState { - const expandedMap: JSONPointerMap = { ...documentState.expandedMap } - const visibleSectionsMap = { ...documentState.visibleSectionsMap } - - for (let i = 0; i < path.length; i++) { - const partialPath = path.slice(0, i) - const partialPointer = compileJSONPointer(partialPath) +export function expandVisibleSection(state: ArrayDocumentState, index: number): ArrayDocumentState { + if (inVisibleSection(state.visibleSections, index)) { + return state + } - const value = getIn(json, partialPath) + const start = currentRoundNumber(index) + const end = nextRoundNumber(start) + const newVisibleSection = { start, end } - if (isObjectOrArray(value)) { - expandedMap[partialPointer] = true - } + return { + ...state, + visibleSections: mergeSections(state.visibleSections.concat(newVisibleSection)) + } +} - // if needed, enlarge the expanded sections such that the search result becomes visible in the array - if (Array.isArray(value) && i < path.length) { - const sections = visibleSectionsMap[partialPointer] || DEFAULT_VISIBLE_SECTIONS - const index = int(path[i]) +export function toRecursiveStatePath(json: unknown, path: JSONPath): JSONPath { + let value = json + const recursiveStatePath: JSONPath = [] - if (!inVisibleSection(sections, index)) { - const start = currentRoundNumber(index) - const end = nextRoundNumber(start) - const newSection = { start, end } - visibleSectionsMap[partialPointer] = mergeSections(sections.concat(newSection)) - } + let i = 0 + while (i < path.length) { + if (Array.isArray(value)) { + const index = path[i] + recursiveStatePath.push('items', index) + value = value[int(index)] + } else if (isObject(value)) { + const key = path[i] + recursiveStatePath.push('properties', key) + value = (value as Record)[key] + } else { + throw new Error(`Cannot convert path: Object or Array expected at index ${i}`) } - } - return { - ...documentState, - expandedMap, - visibleSectionsMap + i++ } + + return recursiveStatePath } /** - * 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, + documentState: DocumentState | undefined, path: JSONPath, - expandedCallback: OnExpand -): DocumentState { - const expandedMap = { ...documentState.expandedMap } - - function recurse(value: unknown) { - const pathIndex = currentPath.length - - if (Array.isArray(value)) { - if (expandedCallback(currentPath)) { - const pointer = compileJSONPointer(currentPath) - expandedMap[pointer] = true + callback: OnExpand +): DocumentState | undefined { + let updatedState = documentState - if (value.length > 0) { - const visibleSections = getVisibleSections(documentState, pointer) + // 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) - forEachVisibleIndex(value, visibleSections, (index) => { - currentPath[pathIndex] = String(index) - recurse(value[index]) - }) + updatedState = updateInDocumentState(json, updatedState, partialPath, (_, nestedState) => { + const updatedState = + isExpandableState(nestedState) && !nestedState.expanded + ? { ...nestedState, expanded: true } + : nestedState - currentPath.pop() - } + if (isArrayRecursiveState(updatedState)) { + const index = int(path[i]) + return expandVisibleSection(updatedState, index) } - } else if (isObject(value)) { - if (expandedCallback(currentPath)) { - expandedMap[compileJSONPointer(currentPath)] = true - const keys = Object.keys(value) - if (keys.length > 0) { - for (const key of keys) { - currentPath[pathIndex] = key - recurse(value[key]) - } + return updatedState + }) + } - currentPath.pop() - } + // 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) + }) +} + +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 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 { - ...documentState, - expandedMap - } + return nestedState + }, + (nestedState) => isExpandableState(nestedState) && nestedState.expanded + ) } -// TODO: write unit tests -export function expandSingleItem(documentState: DocumentState, path: JSONPath): DocumentState { - return { - ...documentState, - expandedMap: { - ...documentState.expandedMap, - [compileJSONPointer(path)]: true - } - } +export function collapsePath( + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath, + recursive: boolean +): DocumentState | undefined { + return updateInDocumentState(json, documentState, path, (nestedJson, nestedState) => { + return recursive ? _collapseRecursively(nestedJson, nestedState, path) : _collapse(nestedState) + }) } -// TODO: write unit tests -export function collapsePath(documentState: DocumentState, path: JSONPath): DocumentState { - // delete the expanded state of the path and all it's nested paths - const expandedMap = deletePath(documentState.expandedMap, path) - const enforceStringMap = deletePath(documentState.enforceStringMap, path) - const visibleSectionsMap = deletePath(documentState.visibleSectionsMap, path) +function _collapse(documentState: T): T { + if (isArrayRecursiveState(documentState) && documentState.expanded) { + return { ...documentState, expanded: false, visibleSections: DEFAULT_VISIBLE_SECTIONS } + } - return { - ...documentState, - expandedMap, - enforceStringMap, - visibleSectionsMap + if (isObjectRecursiveState(documentState) && documentState.expanded) { + return { ...documentState, expanded: false } } -} -// TODO: write unit tests -export function setEnforceString( - documentState: DocumentState, - pointer: JSONPointer, - enforceString: boolean -): DocumentState { - if (enforceString) { - const updatedEnforceString = { ...documentState.enforceStringMap } - updatedEnforceString[pointer] = enforceString + return documentState +} - return { - ...documentState, - enforceStringMap: updatedEnforceString - } - } else { - // remove if defined - if (typeof documentState.enforceStringMap[pointer] === 'boolean') { - const updatedEnforceString = { ...documentState.enforceStringMap } - delete updatedEnforceString[pointer] - return { - ...documentState, - enforceStringMap: updatedEnforceString - } - } else { - return documentState - } - } +function _collapseRecursively( + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath +): DocumentState | undefined { + return _transformDocumentState( + json, + documentState, + path, + (_, nestedState) => _collapse(nestedState), + () => true + ) } /** @@ -260,17 +416,19 @@ export function setEnforceString( */ export function expandSection( json: unknown, - documentState: DocumentState, - pointer: JSONPointer, + documentState: DocumentState | undefined, + path: JSONPath, section: Section -): DocumentState { - return { - ...documentState, - visibleSectionsMap: { - ...documentState.visibleSectionsMap, - [pointer]: mergeSections(getVisibleSections(documentState, pointer).concat(section)) +): DocumentState | undefined { + return updateInDocumentState(json, documentState, path, (_value, state) => { + if (!isArrayRecursiveState(state)) { + return state } - } + + const visibleSections = mergeSections(state.visibleSections.concat(section)) + + return { ...state, visibleSections } + }) } export function syncKeys(actualKeys: string[], prevKeys?: string[]): string[] { @@ -294,343 +452,220 @@ export function syncKeys(actualKeys: string[], prevKeys?: string[]): string[] { */ export function documentStatePatch( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, operations: JSONPatchDocument -): { json: unknown; documentState: DocumentState } { - const updatedJson: unknown = immutableJSONPatch(json, operations) - - const updatedDocumentState = operations.reduce((updatingState, operation) => { - if (isJSONPatchAdd(operation)) { - return documentStateAdd(updatedJson, updatingState, operation) - } - - if (isJSONPatchRemove(operation)) { - return documentStateRemove(updatedJson, updatingState, operation) - } +): { json: unknown; documentState: DocumentState | undefined } { + const initial = { json, documentState } - if (isJSONPatchReplace(operation)) { - return documentStateReplace(updatedJson, updatingState, operation) - } - - if (isJSONPatchCopy(operation) || isJSONPatchMove(operation)) { - return documentStateMoveOrCopy(updatedJson, updatingState, operation) + const result = operations.reduce((current, operation) => { + return { + json: immutableJSONPatch(current.json, [operation]), + documentState: _documentStatePatch(current.json, current.documentState, operation) } - - return updatingState - }, documentState) + }, initial) return { - json: updatedJson, - documentState: updatedDocumentState + json: result.json, + documentState: syncDocumentState(result.json, result.documentState) // sync to clean up leftover state } } -export function documentStateAdd( +function _documentStatePatch( json: unknown, - documentState: DocumentState, - operation: JSONPatchAdd -): DocumentState { - const path = parsePath(json, operation.path) - const parentPath = initial(path) - const parentPointer = compileJSONPointer(parentPath) - const parent = getIn(json, parentPath) - - if (isJSONArray(parent)) { - const index = int(last(path) as string) - - // shift all paths of the relevant parts of the state - const expandedMap = shiftPath(documentState.expandedMap, parentPath, index, 1) - const enforceStringMap = shiftPath(documentState.enforceStringMap, parentPath, index, 1) - let visibleSectionsMap = shiftPath(documentState.visibleSectionsMap, parentPath, index, 1) - - // shift visible sections of array - visibleSectionsMap = updateInPathsMap(visibleSectionsMap, parentPointer, (sections) => - shiftVisibleSections(sections, index, 1) - ) - - return { - ...documentState, - expandedMap, - enforceStringMap, - visibleSectionsMap - } + documentState: DocumentState | undefined, + operation: JSONPatchOperation +): DocumentState | undefined { + if (isJSONPatchAdd(operation)) { + return documentStateAdd(json, documentState, operation, undefined) } - // object, nothing to do - return documentState -} - -export function documentStateRemove( - updatedJson: unknown, - documentState: DocumentState, - operation: JSONPatchRemove -): DocumentState { - const path = parsePath(updatedJson, operation.path) - const parentPath = initial(path) - const parentPointer = compileJSONPointer(parentPath) - const parent = getIn(updatedJson, parentPath) - - let { expandedMap, enforceStringMap, visibleSectionsMap } = documentState - - // delete the path itself and its children - expandedMap = deletePath(expandedMap, path) - enforceStringMap = deletePath(enforceStringMap, path) - visibleSectionsMap = deletePath(visibleSectionsMap, path) - - if (isJSONArray(parent)) { - const index = int(last(path) as string) - - // shift all paths of the relevant parts of the state - expandedMap = shiftPath(expandedMap, parentPath, index, -1) - enforceStringMap = shiftPath(enforceStringMap, parentPath, index, -1) - visibleSectionsMap = shiftPath(visibleSectionsMap, parentPath, index, -1) - - // shift visible sections of array - visibleSectionsMap = updateInPathsMap(visibleSectionsMap, parentPointer, (sections) => - shiftVisibleSections(sections, index, -1) - ) - } - - return { - ...documentState, - expandedMap, - enforceStringMap, - visibleSectionsMap - } -} - -export function documentStateReplace( - updatedJson: unknown, - documentState: DocumentState, - operation: JSONPatchReplace -): DocumentState { - const pointer = operation.path - - // cleanup state from paths that are removed now - const expandedMap = cleanupNonExistingPaths(updatedJson, documentState.expandedMap) - const enforceStringMap = cleanupNonExistingPaths(updatedJson, documentState.enforceStringMap) - const visibleSectionsMap = cleanupNonExistingPaths(updatedJson, documentState.visibleSectionsMap) - - // cleanup props of the object/array/value itself that are not applicable anymore - if (!isJSONObject(operation.value) && !isJSONArray(operation.value)) { - delete expandedMap[pointer] - } - if (!isJSONArray(operation.value)) { - delete visibleSectionsMap[pointer] - } - if (isJSONObject(operation.value) || isJSONArray(operation.value)) { - delete enforceStringMap[pointer] + if (isJSONPatchRemove(operation)) { + return documentStateRemove(json, documentState, operation) } - return { - ...documentState, - expandedMap, - enforceStringMap, - visibleSectionsMap - } -} + if (isJSONPatchReplace(operation)) { + const path = parsePath(json, operation.path) + const enforceString = getEnforceString(json, documentState, path) + if (enforceString) { + // ensure the enforceString setting is not lost when for example changing "123" + // into "abc" and later back to "123", so we now make it explicit. + return setInDocumentState(json, documentState, path, { type: 'value', enforceString }) + } -export function documentStateMoveOrCopy( - updatedJson: unknown, - documentState: DocumentState, - operation: JSONPatchCopy | JSONPatchMove -): DocumentState { - if (isJSONPatchMove(operation) && operation.from === operation.path) { - // nothing to do + // nothing special to do (all is handled by syncDocumentState) return documentState } - // get the state that we will move or copy, and move it to the new location - const renamePointer = (pointer: JSONPointer) => - operation.path + pointer.substring(operation.from.length) - const expandedMapCopy = movePath( - filterPath(documentState.expandedMap, operation.from), - renamePointer - ) - const enforceStringMapCopy = movePath( - filterPath(documentState.enforceStringMap, operation.from), - renamePointer - ) - const visibleSectionsMapCopy = movePath( - filterPath(documentState.visibleSectionsMap, operation.from), - renamePointer - ) - - // patch the document state: use the remove and add operations to apply a move or copy - // note that `value` is just a fake value, we do not use this for real - let updatedState = documentState - if (isJSONPatchMove(operation)) { - updatedState = documentStateRemove(updatedJson, updatedState, { - op: 'remove', - path: operation.from - }) + if (isJSONPatchCopy(operation) || isJSONPatchMove(operation)) { + return documentStateMoveOrCopy(json, documentState, operation) } - updatedState = documentStateAdd(updatedJson, updatedState, { - op: 'add', - path: operation.path, - value: null - }) - // merge the original and the copied state - const expandedMap = mergePaths(updatedState.expandedMap, expandedMapCopy) - const enforceStringMap = mergePaths(updatedState.enforceStringMap, enforceStringMapCopy) - const visibleSectionsMap = mergePaths(updatedState.visibleSectionsMap, visibleSectionsMapCopy) + return documentState +} - return { - ...documentState, - expandedMap, - enforceStringMap, - visibleSectionsMap +export function getInRecursiveState( + json: unknown, + documentState: T | undefined, + path: JSONPath +): T | undefined { + try { + return getIn(documentState, toRecursiveStatePath(json, path)) + } catch (err) { + return undefined } } -/** - * Delete a path from a PathsMap. Will delete the path and its child paths - * IMPORTANT: will NOT shift array items when an array item is removed, use shiftPath for that - */ -export function deletePath(map: JSONPointerMap, path: JSONPath): JSONPointerMap { - const updatedMap: JSONPointerMap = {} - const pointer = compileJSONPointer(path) - - // partition the contents of the map - Object.keys(map).forEach((itemPointer) => { - if (!startsWithJSONPointer(itemPointer, pointer)) { - updatedMap[itemPointer] = map[itemPointer] - } - }) - - return updatedMap +export function setInRecursiveState( + json: unknown, + recursiveState: T | undefined, + path: JSONPath, + value: unknown, + factory: RecursiveStateFactory +): T | undefined { + const ensuredState = ensureRecursiveState(json, recursiveState, path, factory) + return setIn(ensuredState, toRecursiveStatePath(json, path), value) } -// TODO: unit test -export function filterPath(map: JSONPointerMap, pointer: JSONPointer): JSONPointerMap { - const filteredMap: JSONPointerMap = {} - - Object.keys(map).forEach((itemPointer) => { - if (startsWithJSONPointer(itemPointer, pointer)) { - filteredMap[itemPointer] = map[itemPointer] - } +export function updateInRecursiveState( + json: unknown, + documentState: T | undefined, + path: JSONPath, + transform: (value: unknown, state: T) => T | undefined, + factory: RecursiveStateFactory +): T { + const ensuredState: T = ensureRecursiveState(json, documentState, path, factory) + return updateIn(ensuredState, toRecursiveStatePath(json, path), (nestedState: T) => { + const value = getIn(json, path) + return transform(value, nestedState) }) - - return filteredMap } -// TODO: unit test -export function mergePaths(a: JSONPointerMap, b: JSONPointerMap): JSONPointerMap { - return { ...a, ...b } +export function setInDocumentState( + json: unknown | undefined, + documentState: T | undefined, + path: JSONPath, + value: unknown +): T | undefined { + return setInRecursiveState(json, documentState, path, value, documentStateFactory) } -// TODO: unit test -export function movePath( - map: JSONPointerMap, - changePointer: (pointer: JSONPointer) => JSONPointer -): JSONPointerMap { - const movedMap: JSONPointerMap = {} +export function updateInDocumentState( + json: unknown | undefined, + documentState: T | undefined, + path: JSONPath, + transform: (value: unknown, state: T) => T | undefined +): T { + return updateInRecursiveState(json, documentState, path, transform, documentStateFactory) +} - Object.keys(map).forEach((oldPointer) => { - const newPointer = changePointer(oldPointer) - movedMap[newPointer] = map[oldPointer] - }) +export function deleteInDocumentState( + json: unknown | undefined, + documentState: T | undefined, + path: JSONPath +): T | undefined { + const recursivePath = toRecursiveStatePath(json, path) - return movedMap + return existsIn(documentState, recursivePath) + ? deleteIn(documentState, toRecursiveStatePath(json, path)) + : documentState } -export function shiftPath( - map: JSONPointerMap, - path: JSONPath, - index: number, - offset: number -): JSONPointerMap { - const indexPathPos = path.length - const pointer = compileJSONPointer(path) - - // collect all paths that need to be shifted, with their old path, new path, and value - const matches: { oldPointer: JSONPointer; newPointer: JSONPointer; value: T }[] = [] - for (const itemPointer of Object.keys(map)) { - if (startsWithJSONPointer(itemPointer, pointer)) { - const itemPath: JSONPath = parseJSONPointer(itemPointer) - const pathIndex = int(itemPath[indexPathPos]) - - if (pathIndex >= index) { - itemPath[indexPathPos] = String(pathIndex + offset) - - matches.push({ - oldPointer: itemPointer, - newPointer: compileJSONPointer(itemPath), - value: map[itemPointer] - }) - } - } - } - - // if there are no changes, just return the original map - if (matches.length === 0) { - return map - } +export function documentStateAdd( + json: unknown, + documentState: DocumentState | undefined, + operation: JSONPatchAdd, + stateValue: DocumentState | undefined +): DocumentState | undefined { + const path = parsePath(json, operation.path) + const parentPath = initial(path) - const updatedMap = { ...map } + let updatedState = documentState - // delete all old paths from the map - // we do this *before* inserting new paths to prevent deleting a math that is already moved - matches.forEach((match) => { - delete updatedMap[match.oldPointer] - }) + updatedState = updateInDocumentState(json, updatedState, parentPath, (_parent, arrayState) => { + if (!isArrayRecursiveState(arrayState)) { + return arrayState + } - // insert shifted paths in the map - matches.forEach((match) => { - updatedMap[match.newPointer] = match.value + const index = int(last(path) as string) + const { items, visibleSections } = arrayState + return { + ...arrayState, + items: + index < items.length + ? insertItemsAt(items, index, stateValue !== undefined ? [stateValue] : Array(1)) + : items, + visibleSections: shiftVisibleSections(visibleSections, index, 1) + } }) - return updatedMap + // object property added, nothing to do + return setInDocumentState(json, updatedState, path, stateValue) } -// TODO: unit test -export function cleanupNonExistingPaths( +export function documentStateRemove( json: unknown, - map: JSONPointerMap -): JSONPointerMap { - const updatedMap: JSONPointerMap = {} + documentState: DocumentState | undefined, + operation: JSONPatchRemove +): DocumentState | undefined { + const path = parsePath(json, operation.path) + const parentPath = initial(path) + const parent = getIn(json, parentPath) - // TODO: for optimization, we could pass a filter callback which allows you to filter paths - // starting with a specific, so you don't need to invoke parseJSONPointer and existsIn for largest part + if (Array.isArray(parent)) { + return updateInDocumentState(json, documentState, parentPath, (_parent, arrayState) => { + if (!isArrayRecursiveState(arrayState)) { + return arrayState + } + + const index = int(last(path) as string) + const { items, visibleSections } = arrayState - Object.keys(map) - .filter((pointer) => existsIn(json, parsePath(json, pointer))) - .forEach((pointer) => { - updatedMap[pointer] = map[pointer] + return { + ...arrayState, + items: items.slice(0, index).concat(items.slice(index + 1)), + visibleSections: shiftVisibleSections(visibleSections, index, -1) + } }) + } - return updatedMap + return deleteInDocumentState(json, documentState, path) } -/** - * Update a value in a PathsMap. - * When the path exists, the callback will be invoked. - * When the path does not exist, the callback is not invoked. - */ -export function updateInPathsMap( - map: JSONPointerMap, - pointer: JSONPointer, - callback: (value: T) => T -) { - const value = map[pointer] +export function documentStateMoveOrCopy( + json: unknown, + documentState: DocumentState | undefined, + operation: JSONPatchCopy | JSONPatchMove +): DocumentState | undefined { + if (isJSONPatchMove(operation) && operation.from === operation.path) { + // nothing to do + return documentState + } - if (pointer in map) { - const updatedValue = callback(value) - if (!isEqual(value, updatedValue)) { - const updatedMap = { ...map } + let updatedState = documentState - if (updatedValue === undefined) { - delete updatedMap[pointer] - } else { - updatedMap[pointer] = updatedValue - } + // get the state that we will move or copy + const from = parsePath(json, operation.from) + const stateValue = getInRecursiveState(json, updatedState, from) - return updatedMap - } + if (isJSONPatchMove(operation)) { + updatedState = documentStateRemove(json, updatedState, { + op: 'remove', + path: operation.from + }) } - return map + updatedState = documentStateAdd( + json, + updatedState, + { + op: 'add', + path: operation.path, + value: null // note that the value is not actually used, so we just use null instead of getting the actual value from the json + }, + stateValue + ) + + return updatedState } /** @@ -671,18 +706,19 @@ function mergeAdjacentSections(visibleSections: VisibleSection[]): VisibleSectio } export function getEnforceString( - value: unknown, - enforceStringMap: JSONPointerMap | undefined, - pointer: JSONPointer, - parser: JSONParser + json: unknown, + documentState: DocumentState | undefined, + path: JSONPath ): boolean { - const enforceString = enforceStringMap ? enforceStringMap[pointer] : undefined + const value = getIn(json, path) + const nestedState = getInRecursiveState(json, documentState, path) + const enforceString = isValueRecursiveState(nestedState) ? nestedState.enforceString : undefined if (typeof enforceString === 'boolean') { return enforceString } - return isStringContainingPrimitiveValue(value, parser) + return isStringContainingPrimitiveValue(value) } export function getNextKeys(keys: string[], key: string, includeKey = false): string[] { @@ -699,30 +735,29 @@ export function getNextKeys(keys: string[], key: string, includeKey = false): st * Get all paths which are visible and rendered */ // TODO: create memoized version of getVisiblePaths which remembers just the previous result if json and state are the same -export function getVisiblePaths(json: unknown, documentState: DocumentState): JSONPath[] { +export function getVisiblePaths( + json: unknown, + documentState: DocumentState | undefined +): JSONPath[] { const paths: JSONPath[] = [] - function _recurse(value: unknown, path: JSONPath) { + function _recurse(value: unknown, state: DocumentState | undefined, path: JSONPath) { paths.push(path) - const pointer = compileJSONPointer(path) - if (value && documentState.expandedMap[pointer] === true) { - if (isJSONArray(value)) { - const visibleSections = getVisibleSections(documentState, pointer) - forEachVisibleIndex(value, visibleSections, (index) => { - _recurse(value[index], path.concat(String(index))) - }) - } + if (isJSONArray(value) && isArrayRecursiveState(state) && state.expanded) { + forEachVisibleIndex(value, state.visibleSections, (index) => { + _recurse(value[index], state.items[index], path.concat(String(index))) + }) + } - if (isJSONObject(value)) { - Object.keys(value).forEach((key) => { - _recurse(value[key], path.concat(key)) - }) - } + if (isJSONObject(value) && isObjectRecursiveState(state) && state.expanded) { + Object.keys(value).forEach((key) => { + _recurse(value[key], state.properties[key], path.concat(key)) + }) } } - _recurse(json, []) + _recurse(json, documentState, []) return paths } @@ -734,7 +769,7 @@ export function getVisiblePaths(json: unknown, documentState: DocumentState): JS // TODO: create memoized version of getVisibleCaretPositions which remembers just the previous result if json and state are the same export function getVisibleCaretPositions( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, includeInside = true ): CaretPosition[] { const paths: CaretPosition[] = [] @@ -742,14 +777,16 @@ export function getVisibleCaretPositions( function _recurse(value: unknown, path: JSONPath) { paths.push({ path, type: CaretType.value }) - const pointer = compileJSONPointer(path) - if (value && documentState.expandedMap[pointer] === true) { + const valueState = getInRecursiveState(json, documentState, path) + if (value && isExpandableState(valueState) && valueState.expanded) { if (includeInside) { paths.push({ path, type: CaretType.inside }) } if (isJSONArray(value)) { - const visibleSections = getVisibleSections(documentState, pointer) + const visibleSections = isArrayRecursiveState(valueState) + ? valueState.visibleSections + : DEFAULT_VISIBLE_SECTIONS forEachVisibleIndex(value, visibleSections, (index) => { const itemPath = path.concat(String(index)) @@ -790,9 +827,9 @@ export function getVisibleCaretPositions( // TODO: write tests for getPreviousVisiblePath export function getPreviousVisiblePath( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, path: JSONPath -): JSONPath | null { +): JSONPath | undefined { const visiblePaths = getVisiblePaths(json, documentState) const visiblePathPointers = visiblePaths.map(compileJSONPointer) const pathPointer = compileJSONPointer(path) @@ -802,7 +839,7 @@ export function getPreviousVisiblePath( return visiblePaths[index - 1] } - return null + return undefined } /** @@ -812,9 +849,9 @@ export function getPreviousVisiblePath( // TODO: write tests for getNextVisiblePath export function getNextVisiblePath( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, path: JSONPath -): JSONPath | null { +): JSONPath | undefined { const visiblePaths = getVisiblePaths(json, documentState) const visiblePathPointers = visiblePaths.map(compileJSONPointer) const index = visiblePathPointers.indexOf(compileJSONPointer(path)) @@ -823,41 +860,44 @@ export function getNextVisiblePath( return visiblePaths[index + 1] } - return null + return undefined } /** * Expand recursively when the expanded contents is small enough, * else expand in a minimalistic way */ -// TODO: write unit test -export function expandRecursive( - json: unknown, - documentState: DocumentState, - path: JSONPath -): DocumentState { - const expandContents: unknown | undefined = getIn(json, path) - if (expandContents === undefined) { - return documentState - } +export function expandSmart( + json: unknown | undefined, + documentState: DocumentState | undefined, + path: JSONPath, + maxSize: number = MAX_DOCUMENT_SIZE_EXPAND_ALL +): DocumentState | undefined { + 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/dragging.test.ts b/src/lib/logic/dragging.test.ts index cc406802..0c501a0b 100644 --- a/src/lib/logic/dragging.test.ts +++ b/src/lib/logic/dragging.test.ts @@ -4,7 +4,6 @@ import { onMoveSelection } from './dragging.js' import type { MoveSelectionResult } from './dragging.js' import { deepStrictEqual, strictEqual } from 'assert' import { isEqual } from 'lodash-es' -import { createDocumentState } from './documentState.js' import type { RenderedItem } from '../types' import { immutableJSONPatch } from 'immutable-json-patch' @@ -14,11 +13,7 @@ describe('dragging', () => { const json = { array: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] } - let documentState = createDocumentState({ json, expand: () => true }) - documentState = { - ...documentState, - selection: createMultiSelection(['array', '3'], ['array', '5']) - } + const selection = createMultiSelection(['array', '3'], ['array', '5']) const allItems: RenderedItem[] = json.array.map((item, index) => ({ path: ['array', String(index)], @@ -34,7 +29,7 @@ describe('dragging', () => { }): MoveSelectionResult { return onMoveSelection({ json, - documentState, + selection, deltaY, items }) @@ -148,11 +143,7 @@ describe('dragging', () => { const json = { object: { a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6 } } - const documentState = createDocumentState({ - json, - expand: () => true, - select: () => createMultiSelection(['object', 'c'], ['object', 'e']) - }) + const selection = createMultiSelection(['object', 'c'], ['object', 'e']) const allItems = Object.keys(json.object).map((key) => ({ path: ['object', key], height: itemHeight @@ -167,7 +158,7 @@ describe('dragging', () => { }) { return onMoveSelection({ json, - documentState, + selection, deltaY, items }) diff --git a/src/lib/logic/dragging.ts b/src/lib/logic/dragging.ts index b5fcba86..80572354 100644 --- a/src/lib/logic/dragging.ts +++ b/src/lib/logic/dragging.ts @@ -4,7 +4,6 @@ import type { JSONPatchDocument } from 'immutable-json-patch' import { getIn } from 'immutable-json-patch' import { moveInsideParent } from './operations.js' import type { - DocumentState, DragInsideAction, DragInsideProps, JSONSelection, @@ -14,32 +13,31 @@ import type { export interface MoveSelectionProps { json: unknown - documentState: DocumentState + selection: JSONSelection | undefined deltaY: number items: RenderedItem[] } export interface MoveSelectionResult { operations: JSONPatchDocument | undefined - updatedSelection: JSONSelection | null + updatedSelection: JSONSelection | undefined offset: number } export function onMoveSelection({ json, - documentState, + selection, deltaY, items }: MoveSelectionProps): MoveSelectionResult { - if (!documentState.selection) { + if (!selection) { return { operations: undefined, - updatedSelection: null, + updatedSelection: undefined, offset: 0 } } - const selection = documentState.selection const dragInsideAction = deltaY < 0 ? findSwapPathUp({ json, selection, deltaY, items }) @@ -48,7 +46,7 @@ export function onMoveSelection({ if (!dragInsideAction || dragInsideAction.offset === 0) { return { operations: undefined, - updatedSelection: null, + updatedSelection: undefined, offset: 0 } } @@ -74,7 +72,7 @@ export function onMoveSelection({ // object return { operations, - updatedSelection: null, + updatedSelection: undefined, offset: dragInsideAction.offset } } diff --git a/src/lib/logic/operations.test.ts b/src/lib/logic/operations.test.ts index 8950afe8..f5804919 100644 --- a/src/lib/logic/operations.test.ts +++ b/src/lib/logic/operations.test.ts @@ -15,15 +15,15 @@ import { immutableJSONPatch } from 'immutable-json-patch' describe('operations', () => { describe('createNewValue', () => { test('should create a value of type "value"', () => { - assert.strictEqual(createNewValue({}, null, 'value'), '') + assert.strictEqual(createNewValue({}, undefined, 'value'), '') }) test('should create a value of type "object"', () => { - assert.deepStrictEqual(createNewValue({}, null, 'object'), {}) + assert.deepStrictEqual(createNewValue({}, undefined, 'object'), {}) }) test('should create a value of type "array"', () => { - assert.deepStrictEqual(createNewValue({}, null, 'array'), []) + assert.deepStrictEqual(createNewValue({}, undefined, 'array'), []) }) test('should create a simple value via type "structure"', () => { @@ -112,12 +112,10 @@ describe('operations', () => { describe('moveInsideParent', () => { test('should move a selection up inside an array', () => { const json = { array: [0, 1, 2, 3, 4, 5] } - const documentState = createDocumentState({ - json, - select: () => createMultiSelection(['array', '3'], ['array', '4']) - }) + const documentState = createDocumentState({ json, expand: () => true }) + const selection = createMultiSelection(['array', '3'], ['array', '4']) const path = ['array', '1'] - const operations = moveInsideParent(json, documentState.selection, { + const operations = moveInsideParent(json, selection, { beforePath: path, offset: 0 }) @@ -132,12 +130,10 @@ describe('operations', () => { test('should move a selection down inside an array', () => { const json = { array: [0, 1, 2, 3, 4, 5] } - const documentState = createDocumentState({ - json, - select: () => createMultiSelection(['array', '1'], ['array', '2']) - }) + const documentState = createDocumentState({ json, expand: () => true }) + const selection = createMultiSelection(['array', '1'], ['array', '2']) const path = ['array', '4'] - const operations = moveInsideParent(json, documentState.selection, { + const operations = moveInsideParent(json, selection, { beforePath: path, offset: 0 }) @@ -152,13 +148,10 @@ describe('operations', () => { test('should move a selection up inside an object', () => { const json = { object: { a: 'a', b: 'b', c: 'c', d: 'd', e: 'e' } } - const documentState = createDocumentState({ - json, - select: () => createMultiSelection(['object', 'c'], ['object', 'd']), - expand: () => true - }) + const documentState = createDocumentState({ json, expand: () => true }) + const selection = createMultiSelection(['object', 'c'], ['object', 'd']) const path = ['object', 'b'] - const operations = moveInsideParent(json, documentState.selection, { + const operations = moveInsideParent(json, selection, { beforePath: path, offset: 0 }) @@ -179,13 +172,10 @@ describe('operations', () => { test('should move a selection down inside an object', () => { const json = { object: { a: 'a', b: 'b', c: 'c', d: 'd', e: 'e' } } - const documentState = createDocumentState({ - json, - select: () => createMultiSelection(['object', 'b'], ['object', 'c']), - expand: () => true - }) + const documentState = createDocumentState({ json, expand: () => true }) + const selection = createMultiSelection(['object', 'b'], ['object', 'c']) const path = ['object', 'e'] - const operations = moveInsideParent(json, documentState.selection, { + const operations = moveInsideParent(json, selection, { beforePath: path, offset: 0 }) diff --git a/src/lib/logic/operations.ts b/src/lib/logic/operations.ts index 7da117ee..7e450585 100644 --- a/src/lib/logic/operations.ts +++ b/src/lib/logic/operations.ts @@ -339,7 +339,7 @@ export function extract(json: unknown, selection: JSONSelection): JSONPatchDocum // TODO: write unit tests export function insert( json: unknown, - selection: JSONSelection | null, + selection: JSONSelection | undefined, clipboardText: string, parser: JSONParser ): JSONPatchDocument { @@ -445,7 +445,7 @@ export function insert( export function moveInsideParent( json: unknown, - selection: JSONSelection | null, + selection: JSONSelection | undefined, dragInsideAction: DragInsideAction ): JSONPatchDocument { if (!selection) { @@ -529,7 +529,7 @@ export function moveInsideParent( export function createNewValue( json: unknown | undefined, - selection: JSONSelection | null, + selection: JSONSelection | undefined, valueType: 'object' | 'array' | 'structure' | 'value' ): unknown { if (valueType === 'object') { @@ -642,7 +642,7 @@ export function clipboardToValues(clipboardText: string, parser: JSONParser): Cl export function createRemoveOperations( json: unknown, selection: JSONSelection -): { newSelection: JSONSelection | null; operations: JSONPatchDocument } { +): { newSelection: JSONSelection | undefined; operations: JSONPatchDocument } { if (isKeySelection(selection)) { // FIXME: DOESN'T work yet const parentPath = initial(selection.path) @@ -678,7 +678,7 @@ export function createRemoveOperations( // there is no parent, this is the root document const operations: JSONPatchDocument = [{ op: 'replace', path: '', value: '' }] - const newSelection = createValueSelection([], false) + const newSelection = createValueSelection([]) return { operations, newSelection } } diff --git a/src/lib/logic/search.test.ts b/src/lib/logic/search.test.ts index 38ba8837..b0362b55 100644 --- a/src/lib/logic/search.test.ts +++ b/src/lib/logic/search.test.ts @@ -1,4 +1,4 @@ -import { test, describe } from 'vitest' +import { describe, test } from 'vitest' import assert from 'assert' import type { JSONPath } from 'immutable-json-patch' import { immutableJSONPatch } from 'immutable-json-patch' @@ -7,11 +7,18 @@ import { createSearchAndReplaceAllOperations, createSearchAndReplaceOperations, findCaseInsensitiveMatches, + flattenSearchResults, replaceText, search, - splitValue + splitValue, + toRecursiveSearchResults } from './search.js' -import type { ExtendedSearchResultItem, SearchOptions, SearchResultItem } from '$lib/types.js' +import type { + ExtendedSearchResultItem, + SearchResults, + SearchOptions, + SearchResultItem +} from '$lib/types.js' import { SearchField } from '$lib/types.js' import { createKeySelection, createValueSelection } from './selection.js' @@ -324,7 +331,7 @@ describe('search', () => { } ]) - assert.deepStrictEqual(newSelection, createValueSelection(['hello world'], false)) + assert.deepStrictEqual(newSelection, createValueSelection(['hello world'])) const updatedJson = immutableJSONPatch(json, operations) assert.deepStrictEqual(updatedJson, { @@ -357,7 +364,7 @@ describe('search', () => { { op: 'move', from: '/after', path: '/after' } ]) - assert.deepStrictEqual(newSelection, createKeySelection(['hello *'], false)) + assert.deepStrictEqual(newSelection, createKeySelection(['hello *'])) const updatedJson = immutableJSONPatch(json, operations) assert.deepStrictEqual(updatedJson, { @@ -520,7 +527,7 @@ describe('search', () => { { op: 'move', from: '/after', path: '/after' } ]) - assert.deepStrictEqual(newSelection, createKeySelection(['hello *'], false)) + assert.deepStrictEqual(newSelection, createKeySelection(['hello *'])) const updatedJson = immutableJSONPatch(json, operations) assert.deepStrictEqual(updatedJson, { @@ -591,6 +598,156 @@ describe('search', () => { value: 4 }) }) + + describe('toRecursiveSearchResult', () => { + const json = { + b: { c: 'a' }, + a: [{ a: 'b', c: 'a' }, 'e', 'a'] + } + + const expectedSearchResults: ExtendedSearchResultItem[] = [ + { + path: ['b', 'c'], + field: SearchField.value, + fieldIndex: 0, + start: 0, + end: 1, + active: false + }, + { + path: ['a'], + field: SearchField.key, + fieldIndex: 0, + start: 0, + end: 1, + active: true + }, + { + path: ['a', '0', 'a'], + field: SearchField.key, + fieldIndex: 0, + start: 0, + end: 1, + active: false + }, + { + path: ['a', '0', 'c'], + field: SearchField.value, + fieldIndex: 0, + start: 0, + end: 1, + active: false + }, + { + path: ['a', '2'], + field: SearchField.value, + fieldIndex: 0, + start: 0, + end: 1, + active: false + } + ] + + const expectedRecursiveSearchResult: SearchResults = { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + c: { type: 'value', searchResults: [expectedSearchResults[0]] } + } + }, + a: { + type: 'array', + searchResults: [expectedSearchResults[1]], + // eslint-disable-next-line no-sparse-arrays + items: [ + { + type: 'object', + properties: { + a: { type: 'value', searchResults: [expectedSearchResults[2]] }, + c: { type: 'value', searchResults: [expectedSearchResults[3]] } + } + }, + , + { type: 'value', searchResults: [expectedSearchResults[4]] } + ] + } + } + } + + test('should create recursive search result', () => { + const activeIndex = 1 + const results = search('a', json).map((item, index) => ({ + ...item, + active: index === activeIndex + })) + + assert.deepStrictEqual(results, expectedSearchResults) + + const recursiveResults = toRecursiveSearchResults(json, results) + + assert.deepStrictEqual(recursiveResults, expectedRecursiveSearchResult) + }) + + test('should flatten recursive search result', () => { + const flatResults = flattenSearchResults(expectedRecursiveSearchResult) + + assert.deepStrictEqual(flatResults, expectedSearchResults) + }) + + test('should merge recursive search results in a single object', () => { + const json = { + a: 'aha' + } + + const activeIndex = 1 + const results = search('a', json).map((item, index) => ({ + ...item, + active: index === activeIndex + })) + + const expected = [ + { + path: ['a'], + field: SearchField.key, + fieldIndex: 0, + start: 0, + end: 1, + active: false + }, + { + path: ['a'], + field: SearchField.value, + fieldIndex: 0, + start: 0, + end: 1, + active: true + }, + { + path: ['a'], + field: SearchField.value, + fieldIndex: 1, + start: 2, + end: 3, + active: false + } + ] + + assert.deepStrictEqual(results, expected) + + const recursiveResults = toRecursiveSearchResults(json, results) + assert.deepStrictEqual(recursiveResults, { + type: 'object', + properties: { + a: { + type: 'value', + searchResults: [expected[0], expected[1], expected[2]] + } + } + }) + }) + }) }) // helper function to collect matches diff --git a/src/lib/logic/search.ts b/src/lib/logic/search.ts index 662042c4..814f3582 100644 --- a/src/lib/logic/search.ts +++ b/src/lib/logic/search.ts @@ -1,12 +1,7 @@ -import type { - JSONPatchDocument, - JSONPatchOperation, - JSONPath, - JSONPointer -} from 'immutable-json-patch' +import type { JSONPatchDocument, JSONPatchOperation, JSONPath } from 'immutable-json-patch' import { compileJSONPointer, getIn, isJSONArray, isJSONObject } from 'immutable-json-patch' -import { forEachRight, groupBy, initial, isEqual, last } from 'lodash-es' -import { getEnforceString } from './documentState.js' +import { forEachRight, initial, isEqual, last } from 'lodash-es' +import { createRecursiveState, getEnforceString, updateInRecursiveState } from './documentState.js' import { createSelectionFromOperations } from './selection.js' import { rename } from './operations.js' import { stringConvert } from '../utils/typeUtils.js' @@ -14,21 +9,26 @@ import type { DocumentState, ExtendedSearchResultItem, JSONParser, - JSONPointerMap, JSONSelection, SearchOptions, - SearchResult, - SearchResultItem + SearchResultDetails, + SearchResultItem, + SearchResults, + RecursiveStateFactory } from '$lib/types' import { SearchField } from '$lib/types.js' +import { + hasSearchResults, + isArrayRecursiveState, + isObjectRecursiveState +} from 'svelte-jsoneditor/typeguards.js' // TODO: comment // TODO: unit test export function updateSearchResult( - json: unknown, newResultItems: SearchResultItem[], - previousResult: SearchResult | undefined -): SearchResult { + previousResult: SearchResultDetails | undefined +): SearchResultDetails { const activePath = previousResult?.activeItem ? getSearchResultPath(previousResult.activeItem) : undefined @@ -55,14 +55,13 @@ export function updateSearchResult( return { items, - itemsMap: groupBy(items, (item) => compileJSONPointer(item.path)), activeItem, activeIndex } } // TODO: unit test -export function searchNext(searchResult: SearchResult): SearchResult { +export function searchNext(searchResult: SearchResultDetails): SearchResultDetails { const nextActiveIndex = searchResult.activeIndex < searchResult.items.length - 1 ? searchResult.activeIndex + 1 @@ -79,14 +78,13 @@ export function searchNext(searchResult: SearchResult): SearchResult { return { ...searchResult, items, - itemsMap: groupBy(items, (item) => compileJSONPointer(item.path)), activeItem: nextActiveItem, activeIndex: nextActiveIndex } } // TODO: unit test -export function searchPrevious(searchResult: SearchResult): SearchResult { +export function searchPrevious(searchResult: SearchResultDetails): SearchResultDetails { const previousActiveIndex = searchResult.activeIndex > 0 ? searchResult.activeIndex - 1 : searchResult.items.length - 1 @@ -99,7 +97,6 @@ export function searchPrevious(searchResult: SearchResult): SearchResult { return { ...searchResult, items, - itemsMap: groupBy(items, (item) => compileJSONPointer(item.path)), activeItem: previousActiveItem, activeIndex: previousActiveIndex } @@ -276,11 +273,11 @@ export function replaceAllText( export function createSearchAndReplaceOperations( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, replacementText: string, searchResultItem: SearchResultItem, parser: JSONParser -): { newSelection: JSONSelection | null; operations: JSONPatchDocument } { +): { newSelection: JSONSelection | undefined; operations: JSONPatchDocument } { const { field, path, start, end } = searchResultItem if (field === SearchField.key) { @@ -306,14 +303,7 @@ export function createSearchAndReplaceOperations( } const currentValueText = typeof currentValue === 'string' ? currentValue : String(currentValue) - const pointer = compileJSONPointer(path) - const enforceString = getEnforceString( - currentValue, - documentState.enforceStringMap, - pointer, - parser - ) - + const enforceString = getEnforceString(json, documentState, path) const value = replaceText(currentValueText, replacementText, start, end) const operations: JSONPatchOperation[] = [ @@ -337,11 +327,11 @@ export function createSearchAndReplaceOperations( export function createSearchAndReplaceAllOperations( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, searchText: string, replacementText: string, parser: JSONParser -): { newSelection: JSONSelection | null; operations: JSONPatchDocument } { +): { newSelection: JSONSelection | undefined; operations: JSONPatchDocument } { // TODO: to improve performance, we could reuse existing search results (except when hitting a maxResult limit) const searchResultItems = search(searchText, json, { maxResults: Infinity }) @@ -387,7 +377,7 @@ export function createSearchAndReplaceAllOperations( // step 3: call createSearchAndReplaceOperations for each of the matches let allOperations: JSONPatchDocument = [] - let lastNewSelection: JSONSelection | null = null + let lastNewSelection: JSONSelection | undefined deduplicatedMatches.forEach((match) => { // TODO: there is overlap with the logic of createSearchAndReplaceOperations. Can we extract and reuse this logic? const { field, path, items } = match @@ -412,15 +402,7 @@ export function createSearchAndReplaceAllOperations( } const currentValueText = typeof currentValue === 'string' ? currentValue : String(currentValue) - - const pointer = compileJSONPointer(path) - const enforceString = getEnforceString( - currentValue, - documentState.enforceStringMap, - pointer, - parser - ) - + const enforceString = getEnforceString(json, documentState, path) const value = replaceAllText(currentValueText, replacementText, items) const operations: JSONPatchOperation[] = [ @@ -500,28 +482,69 @@ function getSearchResultPath(searchResultItem: SearchResultItem): JSONPath { // TODO: write unit tests export function filterKeySearchResults( - map: JSONPointerMap | undefined, - pointer: JSONPointer + searchResult: SearchResults | undefined ): ExtendedSearchResultItem[] | undefined { - const items = map?.[pointer]?.filter((item: SearchResultItem) => item.field === SearchField.key) - - if (!items || items.length === 0) { - return undefined - } - - return items + return hasSearchResults(searchResult) + ? searchResult.searchResults.filter((result) => result.field === SearchField.key) + : undefined } // TODO: write unit tests export function filterValueSearchResults( - map: JSONPointerMap | undefined, - pointer: JSONPointer + searchResult: SearchResults | undefined ): ExtendedSearchResultItem[] | undefined { - const items = map?.[pointer]?.filter((item: SearchResultItem) => item.field === SearchField.value) + return hasSearchResults(searchResult) + ? searchResult.searchResults.filter((result) => result.field === SearchField.value) + : undefined +} - if (!items || items.length === 0) { - return undefined - } +export function createSearchResults({ json }: { json: unknown }): SearchResults | undefined { + return createRecursiveState({ + json, + factory: searchResultsFactory + }) as SearchResults +} + +export const searchResultsFactory: RecursiveStateFactory = { + createObjectDocumentState: () => ({ type: 'object', properties: {} }), + createArrayDocumentState: () => ({ type: 'array', items: [] }), + createValueDocumentState: () => ({ type: 'value' }) +} + +export function updateInSearchResults( + json: unknown, + searchResults: SearchResults | undefined, + path: JSONPath, + transform: (value: unknown, state: SearchResults) => SearchResults +): SearchResults { + return updateInRecursiveState(json, searchResults, path, transform, searchResultsFactory) +} + +export function toRecursiveSearchResults( + json: unknown, + searchResultItems: ExtendedSearchResultItem[] +): SearchResults | undefined { + return searchResultItems.reduce( + (recursiveState, searchResult) => { + return updateInSearchResults(json, recursiveState, searchResult.path, (_, nestedState) => ({ + ...nestedState, + searchResults: nestedState.searchResults + ? nestedState.searchResults.concat(searchResult) + : [searchResult] + })) + }, + undefined as SearchResults | undefined + ) +} + +export function flattenSearchResults(node: SearchResults | undefined): ExtendedSearchResultItem[] { + const self = node?.searchResults ?? [] + + const nested = isObjectRecursiveState(node) + ? Object.values(node.properties).flatMap(flattenSearchResults) + : isArrayRecursiveState(node) + ? node.items.flatMap(flattenSearchResults) + : [] - return items + return self.concat(nested) } diff --git a/src/lib/logic/selection.test.ts b/src/lib/logic/selection.test.ts index 2ceb9223..3ff84c54 100644 --- a/src/lib/logic/selection.test.ts +++ b/src/lib/logic/selection.test.ts @@ -7,20 +7,21 @@ import { createMultiSelection, createSelectionFromOperations, createValueSelection, - getSelectionPaths, findRootPath, getInitialSelection, getParentPath, getSelectionDown, getSelectionLeft, + getSelectionPaths, getSelectionRight, getSelectionUp, + pathInSelection, + pathStartsWith, selectionIfOverlapping, - selectionToPartialJson, - pathInSelection + selectionToPartialJson } from './selection.js' import { createDocumentState } from './documentState.js' -import { type DocumentState, type JSONSelection, SelectionType } from '$lib/types.js' +import { SelectionType } from '$lib/types.js' describe('selection', () => { const json = { @@ -79,14 +80,14 @@ describe('selection', () => { test('should expand a key selection', () => { const path = ['obj', 'arr'] - const actual = getSelectionPaths(json, createKeySelection(path, false)) + const actual = getSelectionPaths(json, createKeySelection(path)) assert.deepStrictEqual(actual, [['obj', 'arr']]) }) test('should expand a value selection', () => { const path = ['obj', 'arr'] - const actual = getSelectionPaths(json, createValueSelection(path, false)) + const actual = getSelectionPaths(json, createValueSelection(path)) assert.deepStrictEqual(actual, [['obj', 'arr']]) }) @@ -141,10 +142,10 @@ describe('selection', () => { const path2 = ['a'] assert.deepStrictEqual(findRootPath(json, createAfterSelection(path1)), path2) assert.deepStrictEqual(findRootPath(json, createInsideSelection(path1)), path2) - assert.deepStrictEqual(findRootPath(json, createKeySelection(path1, false)), path2) - assert.deepStrictEqual(findRootPath(json, createValueSelection(path1, false)), path2) - assert.deepStrictEqual(findRootPath(json, createValueSelection(path2, false)), path2) - assert.deepStrictEqual(findRootPath(json, createKeySelection(path2, false)), path2) + assert.deepStrictEqual(findRootPath(json, createKeySelection(path1)), path2) + assert.deepStrictEqual(findRootPath(json, createValueSelection(path1)), path2) + assert.deepStrictEqual(findRootPath(json, createValueSelection(path2)), path2) + assert.deepStrictEqual(findRootPath(json, createKeySelection(path2)), path2) assert.deepStrictEqual(findRootPath(json, createMultiSelection(['a'], ['a'])), path2) }) @@ -159,46 +160,30 @@ describe('selection', () => { expand: () => true }) - function withSelection(documentState: DocumentState, selection: JSONSelection): DocumentState { - return { - ...documentState, - selection - } - } - test('getSelectionLeft', () => { assert.deepStrictEqual( - getSelectionLeft(json2, { - ...documentState2, - selection: createValueSelection(['path'], false) - }), - createKeySelection(['path'], false) + getSelectionLeft(json2, documentState2, createValueSelection(['path'])), + createKeySelection(['path']) ) assert.deepStrictEqual( - getSelectionLeft( - json2, - withSelection(documentState2, createKeySelection(['path1'], false)) - ), + getSelectionLeft(json2, documentState2, createKeySelection(['path1'])), createAfterSelection(['path']) ) assert.deepStrictEqual( - getSelectionLeft(json2, withSelection(documentState2, createAfterSelection(['path']))), - createValueSelection(['path'], false) + getSelectionLeft(json2, documentState2, createAfterSelection(['path'])), + createValueSelection(['path']) ) assert.deepStrictEqual( - getSelectionLeft(json2, withSelection(documentState2, createInsideSelection([]))), - createValueSelection([], false) + getSelectionLeft(json2, documentState2, createInsideSelection([])), + createValueSelection([]) ) assert.deepStrictEqual( - getSelectionLeft( - json2, - withSelection(documentState2, createMultiSelection(['path1'], ['path2'])) - ), - createKeySelection(['path2'], false) + getSelectionLeft(json2, documentState2, createMultiSelection(['path1'], ['path2'])), + createKeySelection(['path2']) ) }) @@ -206,12 +191,12 @@ describe('selection', () => { const json2 = [1, 2, 3] const documentState2 = createDocumentState({ json: json2, - expand: () => false, - select: () => createValueSelection(['1'], false) + expand: () => false }) + const selection = createValueSelection(['1']) assert.deepStrictEqual( - getSelectionLeft(json2, documentState2), + getSelectionLeft(json2, documentState2, selection), createMultiSelection(['1'], ['1']) ) }) @@ -219,11 +204,7 @@ describe('selection', () => { test('getSelectionLeft: keep anchor path', () => { const keepAnchorPath = true assert.deepStrictEqual( - getSelectionLeft( - json2, - withSelection(documentState2, createValueSelection(['path'], false)), - keepAnchorPath - ), + getSelectionLeft(json2, documentState2, createValueSelection(['path']), keepAnchorPath), { type: SelectionType.multi, anchorPath: ['path'], @@ -234,53 +215,40 @@ describe('selection', () => { test('getSelectionRight', () => { assert.deepStrictEqual( - getSelectionRight( - json2, - withSelection(documentState2, createKeySelection(['path'], false)) - ), - createValueSelection(['path'], false) + getSelectionRight(json2, documentState2, createKeySelection(['path'])), + createValueSelection(['path']) ) assert.deepStrictEqual( - getSelectionRight(json2, withSelection(documentState2, createValueSelection([], false))), + getSelectionRight(json2, documentState2, createValueSelection([])), createInsideSelection([]) ) assert.deepStrictEqual( - getSelectionRight( - json2, - withSelection(documentState2, createValueSelection(['path'], false)) - ), + getSelectionRight(json2, documentState2, createValueSelection(['path'])), createAfterSelection(['path']) ) assert.deepStrictEqual( - getSelectionRight(json2, withSelection(documentState2, createAfterSelection(['path']))), - createKeySelection(['path1'], false) + getSelectionRight(json2, documentState2, createAfterSelection(['path'])), + createKeySelection(['path1']) ) assert.deepStrictEqual( - getSelectionRight(json2, withSelection(documentState2, createInsideSelection(['path']))), - null + getSelectionRight(json2, documentState2, createInsideSelection(['path'])), + undefined ) assert.deepStrictEqual( - getSelectionRight( - json2, - withSelection(documentState2, createMultiSelection(['path1'], ['path2'])) - ), - createValueSelection(['path2'], false) + getSelectionRight(json2, documentState2, createMultiSelection(['path1'], ['path2'])), + createValueSelection(['path2']) ) }) test('getSelectionRight: keep anchor path', () => { const keepAnchorPath = true assert.deepStrictEqual( - getSelectionRight( - json2, - withSelection(documentState2, createKeySelection(['path'], false)), - keepAnchorPath - ), + getSelectionRight(json2, documentState2, createKeySelection(['path']), keepAnchorPath), { type: SelectionType.multi, anchorPath: ['path'], @@ -302,137 +270,103 @@ describe('selection', () => { test('should get selection up from KEY selection', () => { assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createKeySelection(['obj'], false))), - createKeySelection(['a'], false) + getSelectionUp(json2, documentState2, createKeySelection(['obj'])), + createKeySelection(['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createKeySelection(['obj', 'c'], false)) - ), - createKeySelection(['obj'], false) + getSelectionUp(json2, documentState2, createKeySelection(['obj', 'c'])), + createKeySelection(['obj']) ) // jump from key to array value assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createKeySelection(['d'], false))), - createValueSelection(['arr', '1'], false) + getSelectionUp(json2, documentState2, createKeySelection(['d'])), + createValueSelection(['arr', '1']) ) }) test('should get selection up from VALUE selection', () => { assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createValueSelection(['obj'], false)) - ), - createValueSelection(['a'], false) + getSelectionUp(json2, documentState2, createValueSelection(['obj'])), + createValueSelection(['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createValueSelection(['obj', 'c'], false)) - ), - createValueSelection(['obj'], false) + getSelectionUp(json2, documentState2, createValueSelection(['obj', 'c'])), + createValueSelection(['obj']) ) assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createValueSelection(['d'], false))), - createValueSelection(['arr', '1'], false) + getSelectionUp(json2, documentState2, createValueSelection(['d'])), + createValueSelection(['arr', '1']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createValueSelection(['arr', '1'], false)) - ), - createValueSelection(['arr', '0'], false) + getSelectionUp(json2, documentState2, createValueSelection(['arr', '1'])), + createValueSelection(['arr', '0']) ) }) test('should get selection up from AFTER selection', () => { assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createAfterSelection(['arr', '1']))), - createValueSelection(['arr', '1'], false) + getSelectionUp(json2, documentState2, createAfterSelection(['arr', '1'])), + createValueSelection(['arr', '1']) ) // FIXME: this should return a value selection of /obj/c instead of /obj assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createAfterSelection(['obj']))), - createValueSelection(['obj'], false) + getSelectionUp(json2, documentState2, createAfterSelection(['obj'])), + createValueSelection(['obj']) ) }) test('should get selection up from INSIDE selection', () => { assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createInsideSelection(['arr']))), - createValueSelection(['arr'], false) + getSelectionUp(json2, documentState2, createInsideSelection(['arr'])), + createValueSelection(['arr']) ) assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createInsideSelection(['obj']))), - createValueSelection(['obj'], false) + getSelectionUp(json2, documentState2, createInsideSelection(['obj'])), + createValueSelection(['obj']) ) assert.deepStrictEqual( - getSelectionUp(json2, withSelection(documentState2, createInsideSelection([]))), - createValueSelection([], false) + getSelectionUp(json2, documentState2, createInsideSelection([])), + createValueSelection([]) ) }) test('should get selection up from MULTI selection', () => { assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['d'], ['obj'])) - ), - createValueSelection(['a'], false) + getSelectionUp(json2, documentState2, createMultiSelection(['d'], ['obj'])), + createValueSelection(['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['d'], ['obj'])), - true - ), + getSelectionUp(json2, documentState2, createMultiSelection(['d'], ['obj']), true), createMultiSelection(['d'], ['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['obj'], ['d'])) - ), - createValueSelection(['a'], false) + getSelectionUp(json2, documentState2, createMultiSelection(['obj'], ['d'])), + createValueSelection(['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['obj'], ['d'])), - false - ), - createValueSelection(['a'], false) + getSelectionUp(json2, documentState2, createMultiSelection(['obj'], ['d'])), + createValueSelection(['a']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['obj'], ['d'])), - true - ), + getSelectionUp(json2, documentState2, createMultiSelection(['obj'], ['d']), true), createMultiSelection(['obj'], ['arr']) ) assert.deepStrictEqual( - getSelectionUp( - json2, - withSelection(documentState2, createMultiSelection(['a'], ['a'])), - false - ), - createValueSelection([], false) + getSelectionUp(json2, documentState2, createMultiSelection(['a'], ['a'])), + createValueSelection([]) ) }) }) @@ -450,161 +384,107 @@ describe('selection', () => { test('should get selection down from KEY selection', () => { assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createKeySelection(['obj'], false)) - ), - createKeySelection(['obj', 'c'], false) + getSelectionDown(json2, documentState2, createKeySelection(['obj'])), + createKeySelection(['obj', 'c']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createKeySelection(['obj', 'c'], false)) - ), - createKeySelection(['arr'], false) + getSelectionDown(json2, documentState2, createKeySelection(['obj', 'c'])), + createKeySelection(['arr']) ) // jump from key to array value assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createKeySelection(['arr'], false)) - ), - createValueSelection(['arr', '0'], false) + getSelectionDown(json2, documentState2, createKeySelection(['arr'])), + createValueSelection(['arr', '0']) ) }) test('should get selection down from VALUE selection', () => { assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createValueSelection(['obj'], false)) - ), - createValueSelection(['obj', 'c'], false) + getSelectionDown(json2, documentState2, createValueSelection(['obj'])), + createValueSelection(['obj', 'c']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createValueSelection(['obj', 'c'], false)) - ), - createValueSelection(['arr'], false) + getSelectionDown(json2, documentState2, createValueSelection(['obj', 'c'])), + createValueSelection(['arr']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createValueSelection(['arr', '1'], false)) - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createValueSelection(['arr', '1'])), + createValueSelection(['d']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createValueSelection(['arr', '0'], false)) - ), - createValueSelection(['arr', '1'], false) + getSelectionDown(json2, documentState2, createValueSelection(['arr', '0'])), + createValueSelection(['arr', '1']) ) }) test('should get selection down from AFTER selection', () => { assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createAfterSelection(['arr', '0'])) - ), - createValueSelection(['arr', '1'], false) + getSelectionDown(json2, documentState2, createAfterSelection(['arr', '0'])), + createValueSelection(['arr', '1']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createAfterSelection(['arr', '1'])) - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createAfterSelection(['arr', '1'])), + createValueSelection(['d']) ) assert.deepStrictEqual( - getSelectionDown(json2, withSelection(documentState2, createAfterSelection(['obj']))), - createValueSelection(['arr'], false) + getSelectionDown(json2, documentState2, createAfterSelection(['obj'])), + createValueSelection(['arr']) ) }) test('should get selection down from INSIDE selection', () => { assert.deepStrictEqual( - getSelectionDown(json2, withSelection(documentState2, createInsideSelection(['arr']))), - createValueSelection(['arr', '0'], false) + getSelectionDown(json2, documentState2, createInsideSelection(['arr'])), + createValueSelection(['arr', '0']) ) assert.deepStrictEqual( - getSelectionDown(json2, withSelection(documentState2, createInsideSelection(['obj']))), - createValueSelection(['obj', 'c'], false) + getSelectionDown(json2, documentState2, createInsideSelection(['obj'])), + createValueSelection(['obj', 'c']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createInsideSelection(['arr'])), - true - ), + getSelectionDown(json2, documentState2, createInsideSelection(['arr']), true), createMultiSelection(['arr', '0'], ['arr', '0']) ) }) test('should get selection down from MULTI selection', () => { assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['arr'], ['a'])) - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createMultiSelection(['arr'], ['a'])), + createValueSelection(['d']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['arr'], ['a'])), - false - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createMultiSelection(['arr'], ['a'])), + createValueSelection(['d']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['arr'], ['a'])), - true - ), + getSelectionDown(json2, documentState2, createMultiSelection(['arr'], ['a']), true), createMultiSelection(['arr'], ['obj']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['a'], ['arr'])), - false - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createMultiSelection(['a'], ['arr'])), + createValueSelection(['d']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['a'], ['arr'])), - true - ), + getSelectionDown(json2, documentState2, createMultiSelection(['a'], ['arr']), true), createMultiSelection(['a'], ['d']) ) assert.deepStrictEqual( - getSelectionDown( - json2, - withSelection(documentState2, createMultiSelection(['arr'], ['arr'])), - false - ), - createValueSelection(['d'], false) + getSelectionDown(json2, documentState2, createMultiSelection(['arr'], ['arr'])), + createValueSelection(['d']) ) }) @@ -619,12 +499,8 @@ describe('selection', () => { const documentState3 = createDocumentState({ json: json3, expand: () => true }) assert.deepStrictEqual( - getSelectionDown( - json3, - withSelection(documentState3, createAfterSelection(['arr'])), - false - ), - null + getSelectionDown(json3, documentState3, createAfterSelection(['arr'])), + undefined ) }) }) @@ -638,57 +514,51 @@ describe('selection', () => { assert.deepStrictEqual(getInitialSelectionWithState({}), { type: SelectionType.value, - path: [], - edit: false + path: [] }) assert.deepStrictEqual(getInitialSelectionWithState([]), { type: SelectionType.value, - path: [], - edit: false + path: [] }) assert.deepStrictEqual(getInitialSelectionWithState('test'), { type: SelectionType.value, - path: [], - edit: false + path: [] }) assert.deepStrictEqual(getInitialSelectionWithState({ a: 2, b: 3 }), { type: SelectionType.key, - path: ['a'], - edit: false + path: ['a'] }) assert.deepStrictEqual(getInitialSelectionWithState({ a: {} }), { type: SelectionType.key, - path: ['a'], - edit: false + path: ['a'] }) assert.deepStrictEqual(getInitialSelectionWithState([2, 3, 4]), { type: SelectionType.value, - path: ['0'], - edit: false + path: ['0'] }) }) test('should turn selection into text', () => { assert.deepStrictEqual( - selectionToPartialJson(json, createKeySelection(['str'], false), 2, JSON), + selectionToPartialJson(json, createKeySelection(['str']), 2, JSON), 'str' ) assert.deepStrictEqual( - selectionToPartialJson(json, createValueSelection(['str'], false), 2, JSON), + selectionToPartialJson(json, createValueSelection(['str']), 2, JSON), 'hello world' ) assert.deepStrictEqual( - selectionToPartialJson(json, createValueSelection(['obj', 'arr', '1'], false), 2, JSON), + selectionToPartialJson(json, createValueSelection(['obj', 'arr', '1']), 2, JSON), '2' ) assert.deepStrictEqual( selectionToPartialJson(json, createAfterSelection(['str']), 2, JSON), - null + undefined ) assert.deepStrictEqual( selectionToPartialJson(json, createInsideSelection(['str']), 2, JSON), - null + undefined ) assert.deepStrictEqual( @@ -716,7 +586,7 @@ describe('selection', () => { ) assert.deepStrictEqual( - selectionToPartialJson(json, createValueSelection(['obj'], false), 2, JSON), + selectionToPartialJson(json, createValueSelection(['obj']), 2, JSON), JSON.stringify(json.obj, null, 2) ) @@ -740,12 +610,7 @@ describe('selection', () => { const objArr2 = '{\n' + ' "first": 3,\n' + ' "last": 4\n' + '}' assert.deepStrictEqual( - selectionToPartialJson( - json, - createValueSelection(['obj', 'arr', '2'], false), - indentation, - JSON - ), + selectionToPartialJson(json, createValueSelection(['obj', 'arr', '2']), indentation, JSON), objArr2 ) assert.deepStrictEqual( @@ -812,7 +677,7 @@ describe('selection', () => { createSelectionFromOperations(json, [ { op: 'replace', path: '/str', value: 'hello world (updated)' } ]), - createValueSelection(['str'], false) + createValueSelection(['str']) ) }) @@ -823,28 +688,28 @@ describe('selection', () => { { op: 'move', from: '/foo', path: '/foo' }, { op: 'move', from: '/bar', path: '/bar' } ]), - createKeySelection(['strRenamed'], false) + createKeySelection(['strRenamed']) ) }) test('should get selection from renaming the last key of an object', () => { assert.deepStrictEqual( createSelectionFromOperations(json, [{ op: 'move', from: '/arr', path: '/arrRenamed' }]), - createKeySelection(['arrRenamed'], false) + createKeySelection(['arrRenamed']) ) }) test('should get selection from removing a key', () => { assert.deepStrictEqual( createSelectionFromOperations(json, [{ op: 'remove', path: '/str' }]), - null + undefined ) }) test('should get selection from inserting a new root document', () => { assert.deepStrictEqual( createSelectionFromOperations(json, [{ op: 'replace', path: '', value: 'test' }]), - createValueSelection([], false) + createValueSelection([]) ) }) }) @@ -877,16 +742,19 @@ describe('selection', () => { describe('selectionIfOverlapping', () => { test('should determine whether a KeySelection is relevant for given pointer', () => { - const selection = createKeySelection(['obj', 'arr'], false) + const selection = createKeySelection(['obj', 'arr']) assert.deepStrictEqual(selectionIfOverlapping(json, selection, []), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj']), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr']), selection) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), null) + assert.deepStrictEqual( + selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), + undefined + ) }) test('should determine whether a ValueSelection is relevant for given pointer', () => { - const selection = createValueSelection(['obj', 'arr'], false) + const selection = createValueSelection(['obj', 'arr']) assert.deepStrictEqual(selectionIfOverlapping(json, selection, []), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj']), selection) @@ -903,7 +771,10 @@ describe('selection', () => { assert.deepStrictEqual(selectionIfOverlapping(json, selection, []), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj']), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr']), selection) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), null) + assert.deepStrictEqual( + selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), + undefined + ) }) test('should determine whether an InsideSelection is relevant for given pointer', () => { @@ -912,7 +783,10 @@ describe('selection', () => { assert.deepStrictEqual(selectionIfOverlapping(json, selection, []), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj']), selection) assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr']), selection) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), null) + assert.deepStrictEqual( + selectionIfOverlapping(json, selection, ['obj', 'arr', '0']), + undefined + ) }) test('should determine whether a MultiSelection is relevant for given pointer', () => { @@ -941,22 +815,22 @@ describe('selection', () => { selectionIfOverlapping(json, selection, ['obj', 'arr', '2', 'last']), selection ) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['str']), null) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['nill']), null) - assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['bool']), null) + assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['str']), undefined) + assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['nill']), undefined) + assert.deepStrictEqual(selectionIfOverlapping(json, selection, ['bool']), undefined) }) }) describe('pathInSelection', () => { test('should determine path in selection for a KeySelection', () => { - const selection = createKeySelection(['obj'], false) + const selection = createKeySelection(['obj']) assert.strictEqual(pathInSelection(json, selection, ['obj']), true) assert.strictEqual(pathInSelection(json, selection, ['str']), false) assert.strictEqual(pathInSelection(json, selection, ['obj', 'arr', '1']), false) }) test('should determine path in selection for a ValueSelection', () => { - const selection = createValueSelection(['obj'], false) + const selection = createValueSelection(['obj']) assert.strictEqual(pathInSelection(json, selection, ['obj']), true) assert.strictEqual(pathInSelection(json, selection, ['str']), false) assert.strictEqual(pathInSelection(json, selection, ['obj', 'arr', '1']), true) @@ -992,4 +866,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 fdd79753..90aa533b 100644 --- a/src/lib/logic/selection.ts +++ b/src/lib/logic/selection.ts @@ -22,6 +22,8 @@ import type { AfterSelection, CaretPosition, DocumentState, + EditKeySelection, + EditValueSelection, InsideSelection, JSONEditorSelection, JSONParser, @@ -35,40 +37,44 @@ import { CaretType, SelectionType } from '$lib/types.js' import { int } from '$lib/utils/numberUtils.js' export function isAfterSelection( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): selection is AfterSelection { return (selection && selection.type === SelectionType.after) || false } export function isInsideSelection( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): selection is InsideSelection { return (selection && selection.type === SelectionType.inside) || false } -export function isKeySelection(selection: JSONEditorSelection | null): selection is KeySelection { +export function isKeySelection( + selection: JSONEditorSelection | undefined +): selection is KeySelection { return (selection && selection.type === SelectionType.key) || false } export function isValueSelection( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): selection is ValueSelection { return (selection && selection.type === SelectionType.value) || false } export function isMultiSelection( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): selection is MultiSelection { return (selection && selection.type === SelectionType.multi) || false } export function isMultiSelectionWithOneItem( - selection: JSONEditorSelection | null + selection: JSONEditorSelection | undefined ): selection is MultiSelection { return isMultiSelection(selection) && isEqual(selection.focusPath, selection.anchorPath) } -export function isJSONSelection(selection: JSONEditorSelection | null): selection is JSONSelection { +export function isJSONSelection( + selection: JSONEditorSelection | undefined +): selection is JSONSelection { return ( isMultiSelection(selection) || isAfterSelection(selection) || @@ -78,7 +84,9 @@ export function isJSONSelection(selection: JSONEditorSelection | null): selectio ) } -export function isTextSelection(selection: JSONEditorSelection | null): selection is TextSelection { +export function isTextSelection( + selection: JSONEditorSelection | undefined +): selection is TextSelection { return (selection && selection.type === SelectionType.text) || false } @@ -108,7 +116,7 @@ export function getSelectionPaths(json: unknown, selection: JSONSelection): JSON */ export function iterateOverSelection( json: unknown | undefined, - selection: JSONSelection | null, + selection: JSONSelection | undefined, callback: (path: JSONPath) => void | undefined | T ): void | undefined | T { if (!selection) { @@ -215,12 +223,12 @@ export function isSelectionInsidePath(selection: JSONSelection, path: JSONPath): export function getSelectionUp( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, + selection: JSONSelection | undefined, keepAnchorPath = false -): JSONSelection | null { - const selection = documentState.selection +): JSONSelection | undefined { if (!selection) { - return null + return undefined } const focusPath = keepAnchorPath ? getFocusPath(selection) : getStartPath(json, selection) @@ -229,66 +237,66 @@ export function getSelectionUp( if (keepAnchorPath) { // create a multi-selection with multiple nodes if (isInsideSelection(selection) || isAfterSelection(selection)) { - return previousPath !== null ? createMultiSelection(focusPath, focusPath) : null + return previousPath !== undefined ? createMultiSelection(focusPath, focusPath) : undefined } - return previousPath !== null + return previousPath !== undefined ? createMultiSelection(getAnchorPath(selection), previousPath) - : null + : undefined } if (isAfterSelection(selection)) { // select the node itself, not the previous node, // FIXME: when after an expanded object/array, should go to the last item inside the object/array - return createValueSelection(focusPath, false) + return createValueSelection(focusPath) } if (isInsideSelection(selection)) { // select the node itself, not the previous node, - return createValueSelection(focusPath, false) + return createValueSelection(focusPath) } if (isKeySelection(selection)) { - if (previousPath == null || previousPath.length === 0) { - return null + if (previousPath === undefined || previousPath.length === 0) { + return undefined } const parentPath = initial(previousPath) const parent = getIn(json, parentPath) if (Array.isArray(parent) || isEmpty(previousPath)) { // switch to value selection: array has no keys, and root object also not - return createValueSelection(previousPath, false) + return createValueSelection(previousPath) } else { - return createKeySelection(previousPath, false) + return createKeySelection(previousPath) } } if (isValueSelection(selection)) { - return previousPath !== null ? createValueSelection(previousPath, false) : null + return previousPath !== undefined ? createValueSelection(previousPath) : undefined } - if (previousPath !== null) { - return createValueSelection(previousPath, false) + if (previousPath !== undefined) { + return createValueSelection(previousPath) } - return null + return undefined } export function getSelectionDown( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, + selection: JSONSelection | undefined, keepAnchorPath = false -): JSONSelection | null { - const selection = documentState.selection +): JSONSelection | undefined { if (!selection) { - return null + return undefined } const focusPath = keepAnchorPath ? getFocusPath(selection) : getEndPath(json, selection) // 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(documentState, focusPath) + ? collapsePath(json, documentState, focusPath, true) : documentState const nextPath = getNextVisiblePath(json, documentState, focusPath) @@ -297,54 +305,56 @@ export function getSelectionDown( if (keepAnchorPath) { // create a multi-selection with multiple nodes if (isInsideSelection(selection)) { - return nextPath !== null ? createMultiSelection(nextPath, nextPath) : null + return nextPath !== undefined ? createMultiSelection(nextPath, nextPath) : undefined } if (isAfterSelection(selection)) { - return nextPathAfter !== null ? createMultiSelection(nextPathAfter, nextPathAfter) : null + return nextPathAfter !== undefined + ? createMultiSelection(nextPathAfter, nextPathAfter) + : undefined } - return nextPathAfter !== null + return nextPathAfter !== undefined ? createMultiSelection(getAnchorPath(selection), nextPathAfter) - : null + : undefined } if (isAfterSelection(selection)) { - return nextPathAfter !== null ? createValueSelection(nextPathAfter, false) : null + return nextPathAfter !== undefined ? createValueSelection(nextPathAfter) : undefined } if (isInsideSelection(selection)) { - return nextPath !== null ? createValueSelection(nextPath, false) : null + return nextPath !== undefined ? createValueSelection(nextPath) : undefined } if (isValueSelection(selection)) { - return nextPath !== null ? createValueSelection(nextPath, false) : null + return nextPath !== undefined ? createValueSelection(nextPath) : undefined } if (isKeySelection(selection)) { - if (nextPath === null || nextPath.length === 0) { - return null + if (nextPath === undefined || nextPath.length === 0) { + return undefined } const parentPath = initial(nextPath) // not nextPathAfter! const parent = getIn(json, parentPath) if (Array.isArray(parent)) { // switch to value selection: array has no keys - return createValueSelection(nextPath, false) + return createValueSelection(nextPath) } else { - return createKeySelection(nextPath, false) + return createKeySelection(nextPath) } } if (isMultiSelection(selection)) { - return nextPathAfter !== null - ? createValueSelection(nextPathAfter, false) - : nextPath !== null - ? createValueSelection(nextPath, false) - : null + return nextPathAfter !== undefined + ? createValueSelection(nextPathAfter) + : nextPath !== undefined + ? createValueSelection(nextPath) + : undefined } - return null + return undefined } /** @@ -354,9 +364,9 @@ export function getSelectionDown( */ export function getSelectionNextInside( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, path: JSONPath -): JSONSelection | null { +): JSONSelection | undefined { // TODO: write unit tests for getSelectionNextInside const parentPath = initial(path) const childPath = [last(path) as string] @@ -365,7 +375,7 @@ export function getSelectionNextInside( const nextPathInside = parent ? getNextVisiblePath(parent, documentState, childPath) : undefined if (nextPathInside) { - return createValueSelection(parentPath.concat(nextPathInside), false) + return createValueSelection(parentPath.concat(nextPathInside)) } else { return createAfterSelection(path) } @@ -377,12 +387,16 @@ export function getSelectionNextInside( // TODO: unit test export function findCaretAndSiblings( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, + selection: JSONSelection | undefined, includeInside: boolean -): { next: CaretPosition | null; caret: CaretPosition | null; previous: CaretPosition | null } { - const selection = documentState.selection +): { + next: CaretPosition | undefined + caret: CaretPosition | undefined + previous: CaretPosition | undefined +} { if (!selection) { - return { caret: null, previous: null, next: null } + return { caret: undefined, previous: undefined, next: undefined } } const visibleCaretPositions = getVisibleCaretPositions(json, documentState, includeInside) @@ -393,36 +407,36 @@ export function findCaretAndSiblings( }) return { - caret: index !== -1 ? visibleCaretPositions[index] : null, + caret: index !== -1 ? visibleCaretPositions[index] : undefined, - previous: index !== -1 && index > 0 ? visibleCaretPositions[index - 1] : null, + previous: index !== -1 && index > 0 ? visibleCaretPositions[index - 1] : undefined, next: index !== -1 && index < visibleCaretPositions.length - 1 ? visibleCaretPositions[index + 1] - : null + : undefined } } export function getSelectionLeft( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, + selection: JSONSelection | undefined, keepAnchorPath = false, includeInside = true -): JSONSelection | null { - const selection = documentState.selection +): JSONSelection | undefined { if (!selection) { - return null + return undefined } - const { caret, previous } = findCaretAndSiblings(json, documentState, includeInside) + const { caret, previous } = findCaretAndSiblings(json, documentState, selection, includeInside) if (keepAnchorPath) { if (!isMultiSelection(selection)) { return createMultiSelection(selection.path, selection.path) } - return null + return undefined } if (caret && previous) { @@ -437,31 +451,31 @@ export function getSelectionLeft( } if (isMultiSelection(selection) && !Array.isArray(parent)) { - return createKeySelection(selection.focusPath, false) + return createKeySelection(selection.focusPath) } - return null + return undefined } export function getSelectionRight( json: unknown, - documentState: DocumentState, + documentState: DocumentState | undefined, + selection: JSONSelection | undefined, keepAnchorPath = false, includeInside = true -): JSONSelection | null { - const selection = documentState.selection +): JSONSelection | undefined { if (!selection) { - return null + return undefined } - const { caret, next } = findCaretAndSiblings(json, documentState, includeInside) + const { caret, next } = findCaretAndSiblings(json, documentState, selection, includeInside) if (keepAnchorPath) { if (!isMultiSelection(selection)) { return createMultiSelection(selection.path, selection.path) } - return null + return undefined } if (caret && next) { @@ -469,16 +483,19 @@ export function getSelectionRight( } if (isMultiSelection(selection)) { - return createValueSelection(selection.focusPath, false) + return createValueSelection(selection.focusPath) } - return null + return undefined } /** * Get a proper initial selection based on what is visible */ -export function getInitialSelection(json: unknown, documentState: DocumentState): JSONSelection { +export function getInitialSelection( + json: unknown, + documentState: DocumentState | undefined +): JSONSelection { const visiblePaths = getVisiblePaths(json, documentState) // find the first, deepest nested entry (normally a value, not an Object/Array) @@ -492,21 +509,21 @@ export function getInitialSelection(json: unknown, documentState: DocumentState) const path = visiblePaths[index] return path === undefined || path.length === 0 || Array.isArray(getIn(json, initial(path))) - ? createValueSelection(path, false) // Array items and root object/array do not have a key, so select value in that case - : createKeySelection(path, false) + ? createValueSelection(path) // Array items and root object/array do not have a key, so select value in that case + : createKeySelection(path) } export function createSelectionFromOperations( json: unknown, operations: JSONPatchDocument -): JSONSelection | null { +): JSONSelection | undefined { if (operations.length === 1) { const operation = first(operations) as JSONPatchOperation if (operation.op === 'replace') { // a replaced value const path = parsePath(json, operation.path) - return createValueSelection(path, false) + return createValueSelection(path) } } @@ -522,7 +539,7 @@ export function createSelectionFromOperations( // a renamed key const path = parsePath(json, firstOp.path) - return createKeySelection(path, false) + return createKeySelection(path) } } @@ -538,7 +555,7 @@ export function createSelectionFromOperations( .map((operation) => parsePath(json, operation.path)) if (isEmpty(paths)) { - return null + return undefined } // TODO: make this function robust against operations which do not have consecutive paths or have wrongly ordered paths @@ -564,7 +581,7 @@ export function findSharedPath(path1: JSONPath, path2: JSONPath): JSONPath { return path1.slice(0, i) } -export function singleItemSelected(selection: JSONSelection | null): boolean { +export function singleItemSelected(selection: JSONSelection | undefined): boolean { return ( isKeySelection(selection) || isValueSelection(selection) || @@ -578,7 +595,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 @@ -594,36 +610,34 @@ export function pathStartsWith(path: JSONPath, parentPath: JSONPath): boolean { } // TODO: write unit tests -export function removeEditModeFromSelection(documentState: DocumentState): DocumentState { - const selection = documentState.selection - - if ((isKeySelection(selection) || isValueSelection(selection)) && selection.edit) { - return { - ...documentState, - selection: { - ...selection, - edit: false - } - } +export function removeEditModeFromSelection( + selection: JSONSelection | undefined +): JSONSelection | undefined { + if (isEditingSelection(selection)) { + const { type, path } = selection + return { type, path } as KeySelection | ValueSelection } - return documentState + return selection } -export function createKeySelection(path: JSONPath, edit: boolean): KeySelection { - return { - type: SelectionType.key, - path, - edit - } +export function createKeySelection(path: JSONPath): KeySelection { + return { type: SelectionType.key, path } } -export function createValueSelection(path: JSONPath, edit: boolean): ValueSelection { - return { - type: SelectionType.value, - path, - edit - } +export function createEditKeySelection(path: JSONPath, initialValue?: string): EditKeySelection { + return { type: SelectionType.key, path, edit: true, initialValue } +} + +export function createValueSelection(path: JSONPath): ValueSelection { + return { type: SelectionType.value, path } +} + +export function createEditValueSelection( + path: JSONPath, + initialValue?: string +): EditValueSelection { + return { type: SelectionType.value, path, edit: true, initialValue } } export function createInsideSelection(path: JSONPath): InsideSelection { @@ -658,23 +672,23 @@ export function createMultiSelection(anchorPath: JSONPath, focusPath: JSONPath): */ export function selectionToPartialJson( json: unknown, - selection: JSONSelection | null, + selection: JSONSelection | undefined, indentation: number | string | undefined, parser: JSONParser -): string | null { +): string | undefined { if (isKeySelection(selection)) { return String(last(selection.path)) } if (isValueSelection(selection)) { const value = getIn(json, selection.path) - return typeof value === 'string' ? value : parser.stringify(value, null, indentation) ?? null // TODO: customizable indentation? + return typeof value === 'string' ? value : parser.stringify(value, null, indentation) // TODO: customizable indentation? } if (isMultiSelection(selection)) { if (isEmpty(selection.focusPath)) { // root object -> does not have a parent key/index - return parser.stringify(json, null, indentation) ?? null + return parser.stringify(json, null, indentation) } const parentPath = getParentPath(selection) @@ -683,7 +697,7 @@ export function selectionToPartialJson( if (isMultiSelectionWithOneItem(selection)) { // do not suffix a single selected array item with a comma const item = getIn(json, selection.focusPath) - return parser.stringify(item, null, indentation) ?? null + return parser.stringify(item, null, indentation) } else { return getSelectionPaths(json, selection) .map((path) => { @@ -704,26 +718,16 @@ export function selectionToPartialJson( } } - return null + return undefined } -export function isEditingSelection(selection: JSONSelection | null): boolean { - return (isKeySelection(selection) || isValueSelection(selection)) && selection.edit === true -} - -export function updateSelectionInDocumentState( - documentState: DocumentState, - selection: JSONSelection | null, - replaceIfUndefined = true -): DocumentState { - if (!selection && !replaceIfUndefined) { - return documentState - } - - return { - ...documentState, - selection - } +export function isEditingSelection( + selection: JSONSelection | undefined +): selection is EditKeySelection | EditValueSelection { + return ( + (isKeySelection(selection) || isValueSelection(selection)) && + (selection as Record).edit === true + ) } /** @@ -731,11 +735,11 @@ export function updateSelectionInDocumentState( */ // TODO: write unit tests export function selectAll(): JSONSelection { - return createValueSelection([], false) + return createValueSelection([]) } // TODO: write unit tests -export function hasSelectionContents(selection: JSONSelection | null): boolean { +export function hasSelectionContents(selection: JSONSelection | undefined): boolean { return isKeySelection(selection) || isValueSelection(selection) || isMultiSelection(selection) } @@ -743,7 +747,7 @@ export function hasSelectionContents(selection: JSONSelection | null): boolean { * Test whether the current selection can be converted. * That is the case when the selection is a key/value, or a multi selection with only one path */ -export function canConvert(selection: JSONSelection | null): boolean { +export function canConvert(selection: JSONSelection | undefined): boolean { return ( isKeySelection(selection) || isValueSelection(selection) || @@ -752,12 +756,13 @@ export function canConvert(selection: JSONSelection | null): boolean { } // TODO: unit test +// eslint-disable-next-line consistent-return export function fromCaretPosition(caretPosition: CaretPosition): JSONSelection { switch (caretPosition.type) { case CaretType.key: - return createKeySelection(caretPosition.path, false) + return createKeySelection(caretPosition.path) case CaretType.value: - return createValueSelection(caretPosition.path, false) + return createValueSelection(caretPosition.path) case CaretType.after: return createAfterSelection(caretPosition.path) case CaretType.inside: @@ -766,16 +771,13 @@ export function fromCaretPosition(caretPosition: CaretPosition): JSONSelection { } // TODO: unit test -export function fromSelectionType( - json: unknown, - selectionType: SelectionType, - path: JSONPath -): JSONSelection { +// eslint-disable-next-line consistent-return +export function fromSelectionType(selectionType: SelectionType, path: JSONPath): JSONSelection { switch (selectionType) { case SelectionType.key: - return createKeySelection(path, false) + return createKeySelection(path) case SelectionType.value: - return createValueSelection(path, false) + return createValueSelection(path) case SelectionType.after: return createAfterSelection(path) case SelectionType.inside: @@ -788,11 +790,11 @@ export function fromSelectionType( export function selectionIfOverlapping( json: unknown | undefined, - selection: JSONSelection | null, + selection: JSONSelection | undefined, path: JSONPath -): JSONSelection | null { +): JSONSelection | undefined { if (!selection) { - return null + return undefined } if (pathInSelection(json, selection, path)) { @@ -804,12 +806,12 @@ export function selectionIfOverlapping( return selection } - return null + return undefined } export function pathInSelection( json: unknown | undefined, - selection: JSONSelection | null, + selection: JSONSelection | undefined, path: JSONPath ): boolean { if (json === undefined || !selection) { diff --git a/src/lib/logic/table.test.ts b/src/lib/logic/table.test.ts index fabbcf83..6cc83ca5 100644 --- a/src/lib/logic/table.test.ts +++ b/src/lib/logic/table.test.ts @@ -88,6 +88,10 @@ describe('table', () => { // @ts-ignore deepStrictEqual(getColumns(null, false), []) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + deepStrictEqual(getColumns(undefined, false), []) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore deepStrictEqual(getColumns('foo', false), []) @@ -168,49 +172,49 @@ describe('table', () => { test('selectPreviousRow', () => { deepStrictEqual( - selectPreviousRow(columns, createValueSelection(['2', 'id'], false)), - createValueSelection(['1', 'id'], false) + selectPreviousRow(columns, createValueSelection(['2', 'id'])), + createValueSelection(['1', 'id']) ) deepStrictEqual( - selectPreviousRow(columns, createValueSelection(['0', 'id'], false)), - createValueSelection(['0', 'id'], false) + selectPreviousRow(columns, createValueSelection(['0', 'id'])), + createValueSelection(['0', 'id']) ) }) test('selectNextRow', () => { deepStrictEqual( - selectNextRow(json, columns, createValueSelection(['0', 'id'], false)), - createValueSelection(['1', 'id'], false) + selectNextRow(json, columns, createValueSelection(['0', 'id'])), + createValueSelection(['1', 'id']) ) deepStrictEqual( - selectNextRow(json, columns, createValueSelection(['1', 'id'], false)), - createValueSelection(['1', 'id'], false) + selectNextRow(json, columns, createValueSelection(['1', 'id'])), + createValueSelection(['1', 'id']) ) }) test('selectPreviousColumn', () => { deepStrictEqual( - selectPreviousColumn(columns, createValueSelection(['2', 'name'], false)), - createValueSelection(['2', 'id'], false) + selectPreviousColumn(columns, createValueSelection(['2', 'name'])), + createValueSelection(['2', 'id']) ) deepStrictEqual( - selectPreviousColumn(columns, createValueSelection(['2', 'id'], false)), - createValueSelection(['2', 'id'], false) + selectPreviousColumn(columns, createValueSelection(['2', 'id'])), + createValueSelection(['2', 'id']) ) }) test('selectNextColumn', () => { deepStrictEqual( - selectNextColumn(columns, createValueSelection(['2', 'id'], false)), - createValueSelection(['2', 'name'], false) + selectNextColumn(columns, createValueSelection(['2', 'id'])), + createValueSelection(['2', 'name']) ) deepStrictEqual( - selectNextColumn(columns, createValueSelection(['2', 'other'], false)), - createValueSelection(['2', 'other'], false) + selectNextColumn(columns, createValueSelection(['2', 'other'])), + createValueSelection(['2', 'other']) ) }) diff --git a/src/lib/logic/table.ts b/src/lib/logic/table.ts index c27135ca..1f41cefd 100644 --- a/src/lib/logic/table.ts +++ b/src/lib/logic/table.ts @@ -6,16 +6,10 @@ import { parseJSONPointer } from 'immutable-json-patch' import { groupBy, isEmpty, isEqual, mapValues, partition } from 'lodash-es' -import type { - DocumentState, - JSONSelection, - SortedColumn, - TableCellIndex, - ValidationError -} from '$lib/types.js' +import type { JSONSelection, SortedColumn, TableCellIndex, ValidationError } from '$lib/types.js' import { ValidationSeverity } from '$lib/types.js' import { createValueSelection, getFocusPath, pathStartsWith } from './selection.js' -import { isNumber } from '../utils/numberUtils.js' +import { containsNumber } from '../utils/numberUtils.js' import type { Dictionary } from 'lodash' import { stringifyJSONPath } from '$lib/utils/pathUtils.js' import { forEachSample } from '$lib/utils/arrayUtils.js' @@ -275,7 +269,7 @@ export function selectPreviousRow(columns: JSONPath[], selection: JSONSelection) if (rowIndex > 0) { const previousPosition = { rowIndex: rowIndex - 1, columnIndex } const previousPath = fromTableCellPosition(previousPosition, columns) - return createValueSelection(previousPath, false) + return createValueSelection(previousPath) } return selection @@ -291,7 +285,7 @@ export function selectNextRow( if (rowIndex < (json as Array).length - 1) { const nextPosition = { rowIndex: rowIndex + 1, columnIndex } const nextPath = fromTableCellPosition(nextPosition, columns) - return createValueSelection(nextPath, false) + return createValueSelection(nextPath) } return selection @@ -303,7 +297,7 @@ export function selectPreviousColumn(columns: JSONPath[], selection: JSONSelecti if (columnIndex > 0) { const previousPosition = { rowIndex, columnIndex: columnIndex - 1 } const previousPath = fromTableCellPosition(previousPosition, columns) - return createValueSelection(previousPath, false) + return createValueSelection(previousPath) } return selection @@ -315,7 +309,7 @@ export function selectNextColumn(columns: JSONPath[], selection: JSONSelection): if (columnIndex < columns.length - 1) { const nextPosition = { rowIndex, columnIndex: columnIndex + 1 } const nextPath = fromTableCellPosition(nextPosition, columns) - return createValueSelection(nextPath, false) + return createValueSelection(nextPath) } return selection @@ -362,7 +356,7 @@ export function groupValidationErrors( columns: JSONPath[] ): GroupedValidationErrors { const [arrayErrors, rootErrors] = partition(validationErrors, (validationError) => - isNumber(validationError.path[0]) + containsNumber(validationError.path[0]) ) const errorsByRow: Dictionary = groupBy(arrayErrors, findRowIndex) @@ -438,26 +432,19 @@ function findColumnIndex(error: ValidationError, columns: JSONPath[]): number { * Clear the sorted column from the documentState when it is affected by the operations */ export function clearSortedColumnWhenAffectedByOperations( - documentState: DocumentState, + sortedColumn: SortedColumn | undefined, operations: JSONPatchOperation[], columms: JSONPath[] -): DocumentState { +): SortedColumn | undefined { const mustBeCleared = operations.some((operation) => - operationAffectsSortedColumn(documentState.sortedColumn, operation, columms) + operationAffectsSortedColumn(sortedColumn, operation, columms) ) - if (mustBeCleared) { - return { - ...documentState, - sortedColumn: null - } - } - - return documentState + return mustBeCleared ? undefined : sortedColumn } export function operationAffectsSortedColumn( - sortedColumn: SortedColumn | null, + sortedColumn: SortedColumn | undefined, operation: JSONPatchOperation, columns: JSONPath[] ): boolean { diff --git a/src/lib/logic/validation.test.ts b/src/lib/logic/validation.test.ts index d8f45959..7c882a43 100644 --- a/src/lib/logic/validation.test.ts +++ b/src/lib/logic/validation.test.ts @@ -1,6 +1,6 @@ import { test, describe } from 'vitest' import { deepStrictEqual } from 'assert' -import { mapValidationErrors, validateJSON, validateText } from './validation.js' +import { toRecursiveValidationErrors, validateJSON, validateText } from './validation.js' import type { ValidationError } from '$lib/types' import { ValidationSeverity } from '$lib/types.js' import { stringify, parse, isLosslessNumber } from 'lossless-json' @@ -10,44 +10,53 @@ const LosslessJSONParser = { parse, stringify } describe('validation', () => { test('should create a map from a list with validation errors', () => { + const json = { + year: 2084, + pupils: [{ age: 23 }, { age: 26 }, { age: '42' }] + } + + const severity = ValidationSeverity.warning const message1 = 'Number expected' const message2 = 'Year in the past expected' const message3 = 'Contains invalid data' - const error1: ValidationError = { - path: ['pupils', '2', 'age'], - message: message1, - severity: ValidationSeverity.warning - } - const error2: ValidationError = { - path: ['year'], - message: message2, - severity: ValidationSeverity.warning - } + const error1: ValidationError = { path: ['pupils', '2', 'age'], message: message1, severity } + const error2: ValidationError = { path: ['year'], message: message2, severity } + + const childErrorA = { isChildError: true, path: [], message: message3, severity } + const childErrorB = { isChildError: true, path: ['pupils'], message: message3, severity } + const childErrorC = { isChildError: true, path: ['pupils', '2'], message: message3, severity } const validationErrors = [error1, error2] - deepStrictEqual(mapValidationErrors(validationErrors), { - '': { - isChildError: true, - path: [], - message: message3, - severity: ValidationSeverity.warning - }, - '/pupils': { - isChildError: true, - path: ['pupils'], - message: message3, - severity: ValidationSeverity.warning - }, - '/pupils/2': { - isChildError: true, - path: ['pupils', '2'], - message: message3, - severity: ValidationSeverity.warning - }, - '/pupils/2/age': error1, - '/year': error2 + deepStrictEqual(toRecursiveValidationErrors(json, validationErrors), { + type: 'object', + validationError: childErrorA, + properties: { + year: { + type: 'value', + validationError: error2 + }, + pupils: { + type: 'array', + validationError: childErrorB, + // eslint-disable-next-line no-sparse-arrays + items: [ + , + , + { + type: 'object', + validationError: childErrorC, + properties: { + age: { + type: 'value', + validationError: error1 + } + } + } + ] + } + } }) }) @@ -164,7 +173,7 @@ describe('validation', () => { const invalidText = '{ "foo": 42 }' test('should validateText with native parser and valid JSON', () => { - deepStrictEqual(validateText(validText, myValidator, JSON, JSON), null) + deepStrictEqual(validateText(validText, myValidator, JSON, JSON), undefined) }) test('should validateText with native parser and invalid JSON', () => { @@ -174,7 +183,7 @@ describe('validation', () => { }) test('should validateText with lossless parser and valid JSON', () => { - deepStrictEqual(validateText(validText, myValidator, LosslessJSONParser, JSON), null) + deepStrictEqual(validateText(validText, myValidator, LosslessJSONParser, JSON), undefined) }) test('should validateText with lossless parser and invalid JSON', () => { @@ -186,7 +195,7 @@ describe('validation', () => { test('should validateText with two lossless parsers and valid JSON', () => { deepStrictEqual( validateText(validText, myLosslessValidator, LosslessJSONParser, LosslessJSONParser), - null + undefined ) }) @@ -202,7 +211,7 @@ describe('validation', () => { test('should validateText with a non-repairable parse error', () => { const invalidText = '{\n "name": "Joe" }[]' - deepStrictEqual(validateText(invalidText, null, LosslessJSONParser, JSON), { + deepStrictEqual(validateText(invalidText, undefined, LosslessJSONParser, JSON), { isRepairable: false, parseError: { column: 17, @@ -216,7 +225,7 @@ describe('validation', () => { test('should validateText with a repairable parse error', () => { const invalidText = '{\n "name": "Joe"' - deepStrictEqual(validateText(invalidText, null, LosslessJSONParser, JSON), { + deepStrictEqual(validateText(invalidText, undefined, LosslessJSONParser, JSON), { isRepairable: true, parseError: { column: 15, @@ -231,7 +240,7 @@ describe('validation', () => { test('should validateText with duplicate keys', () => { const duplicateKeysText = '{\n "name": "Joe",\n "age": 23,\n "name": "Sarah"\n}' - deepStrictEqual(validateText(duplicateKeysText, null, LosslessJSONParser, JSON), { + deepStrictEqual(validateText(duplicateKeysText, undefined, LosslessJSONParser, JSON), { isRepairable: false, parseError: { column: 3, diff --git a/src/lib/logic/validation.ts b/src/lib/logic/validation.ts index 368a0e7f..79348cbc 100644 --- a/src/lib/logic/validation.ts +++ b/src/lib/logic/validation.ts @@ -2,35 +2,55 @@ import { initial, isEmpty } from 'lodash-es' import type { ContentErrors, JSONParser, - JSONPointerMap, - NestedValidationError, + RecursiveStateFactory, + ValidationErrors, ValidationError, Validator } from '$lib/types.js' import { ValidationSeverity } from '$lib/types.js' -import { compileJSONPointer, type JSONPointer } from 'immutable-json-patch' import { MAX_AUTO_REPAIRABLE_SIZE, MAX_VALIDATABLE_SIZE } from '../constants.js' import { measure } from '../utils/timeUtils.js' import { normalizeJsonParseError } from '../utils/jsonUtils.js' import { createDebug } from '../utils/debug.js' import { jsonrepair } from 'jsonrepair' +import { updateInRecursiveState } from './documentState.js' +import type { JSONPath } from 'immutable-json-patch' const debug = createDebug('validation') +export const validationErrorsFactory: RecursiveStateFactory = { + createObjectDocumentState: () => ({ type: 'object', properties: {} }), + createArrayDocumentState: () => ({ type: 'array', items: [] }), + createValueDocumentState: () => ({ type: 'value' }) +} + +export function updateInValidationErrors( + json: unknown, + errors: ValidationErrors | undefined, + path: JSONPath, + transform: (value: unknown, state: ValidationErrors) => ValidationErrors +): ValidationErrors { + return updateInRecursiveState(json, errors, path, transform, validationErrorsFactory) +} + /** * Create a flat map with validation errors, where the key is the stringified path * and also create error messages for the parent nodes of the nodes having an error. * * Returns a nested object containing the validation errors */ -export function mapValidationErrors( +export function toRecursiveValidationErrors( + json: unknown, validationErrors: ValidationError[] -): JSONPointerMap { - const map: Record = {} +): ValidationErrors | undefined { + let output: ValidationErrors | undefined - // first generate a map with the errors themselves + // first generate the errors themselves validationErrors.forEach((validationError) => { - map[compileJSONPointer(validationError.path)] = validationError + output = updateInValidationErrors(json, output, validationError.path, (_, state) => ({ + ...state, + validationError + })) }) // create error entries for all parent nodes (displayed when the node is collapsed) @@ -39,25 +59,29 @@ export function mapValidationErrors( while (parentPath.length > 0) { parentPath = initial(parentPath) - const parentPointer: JSONPointer = compileJSONPointer(parentPath) - - if (!(parentPointer in map)) { - map[parentPointer] = { - isChildError: true, - path: parentPath, - message: 'Contains invalid data', - severity: ValidationSeverity.warning - } - } + + output = updateInValidationErrors(json, output, parentPath, (_, state) => { + return state.validationError + ? state + : { + ...state, + validationError: { + isChildError: true, + path: parentPath, + message: 'Contains invalid data', + severity: ValidationSeverity.warning + } + } + }) } }) - return map + return output } export function validateJSON( json: unknown, - validator: Validator | null, + validator: Validator | undefined, parser: JSONParser, validationParser: JSONParser ): ValidationError[] { @@ -80,10 +104,10 @@ export function validateJSON( export function validateText( text: string, - validator: Validator | null, + validator: Validator | undefined, parser: JSONParser, validationParser: JSONParser -): ContentErrors | null { +): ContentErrors | undefined { debug('validateText') if (text.length > MAX_VALIDATABLE_SIZE) { @@ -100,7 +124,7 @@ export function validateText( if (text.length === 0) { // new, empty document, do not try to parse - return null + return undefined } try { @@ -112,7 +136,7 @@ export function validateText( ) if (!validator) { - return null + return undefined } // if needed, parse with the validationParser to be able to feed the json to the validator @@ -130,7 +154,7 @@ export function validateText( (duration) => debug(`validate: validated json in ${duration} ms`) ) - return !isEmpty(validationErrors) ? { validationErrors } : null + return !isEmpty(validationErrors) ? { validationErrors } : undefined } catch (err) { const isRepairable = measure( () => canAutoRepair(text, parser), diff --git a/src/lib/plugins/query/javascriptQueryLanguage.ts b/src/lib/plugins/query/javascriptQueryLanguage.ts index 78a467ea..04fc606f 100644 --- a/src/lib/plugins/query/javascriptQueryLanguage.ts +++ b/src/lib/plugins/query/javascriptQueryLanguage.ts @@ -1,7 +1,7 @@ -import { createPropertySelector } from '../../utils/pathUtils.js' -import { parseString } from '../../utils/stringUtils.js' -import type { QueryLanguage, QueryLanguageOptions } from '../../types.js' -import { isInteger } from '../../utils/typeUtils.js' +import { createPropertySelector } from '$lib/utils/pathUtils.js' +import { parseString } from '$lib/utils/stringUtils.js' +import type { QueryLanguage, QueryLanguageOptions } from '$lib/types.js' +import { isInteger } from '$lib/utils/typeUtils.js' const description = `

diff --git a/src/lib/plugins/query/jsonQueryLanguage.test.ts b/src/lib/plugins/query/jsonQueryLanguage.test.ts new file mode 100644 index 00000000..67666cb9 --- /dev/null +++ b/src/lib/plugins/query/jsonQueryLanguage.test.ts @@ -0,0 +1,219 @@ +import { test, describe } from 'vitest' +import assert from 'assert' +import { cloneDeep } from 'lodash-es' +import { LosslessNumber } from 'lossless-json' +import { jsonQueryLanguage } from '$lib/plugins/query/jsonQueryLanguage' + +const { createQuery, executeQuery } = jsonQueryLanguage + +const user1 = { _id: '1', user: { name: 'Stuart', age: 6, registered: true } } +const user3 = { _id: '3', user: { name: 'Kevin', age: 8, registered: false } } +const user2 = { _id: '2', user: { name: 'Bob', age: 7, registered: true, extra: true } } + +const users = [user1, user3, user2] +const originalUsers = cloneDeep([user1, user3, user2]) + +describe('jsonQueryLanguage', () => { + describe('createQuery and executeQuery', () => { + test('should create a and execute an empty query', () => { + const query = createQuery(users, {}) + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(query, '') + assert.deepStrictEqual(result, users) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a filter query for a nested property', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'name'], + relation: '==', + value: 'Bob' + } + }) + assert.deepStrictEqual(query, 'filter(.user.name == "Bob")') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user2]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original data + }) + + test('should create and execute a filter query for a property with special characters in the name', () => { + const data = users.map((item) => ({ 'user name"': item.user.name })) + const originalData = cloneDeep(data) + + const query = createQuery(data, { + filter: { + path: ['user name"'], + relation: '==', + value: 'Bob' + } + }) + assert.deepStrictEqual(query, 'filter(."user name\\"" == "Bob")') + + const result = executeQuery(data, query, JSON) + assert.deepStrictEqual(result, [{ 'user name"': 'Bob' }]) + assert.deepStrictEqual(data, originalData) // must not touch the original data + }) + + test('should create and execute a filter query for the whole array item', () => { + const data = [2, 3, 1] + const originalData = cloneDeep(data) + const query = createQuery(data, { + filter: { + path: [], + relation: '==', + value: '1' + } + }) + assert.deepStrictEqual(query, 'filter(get() == 1)') + + const result = executeQuery(data, query, JSON) + assert.deepStrictEqual(result, [1]) + assert.deepStrictEqual(data, originalData) // must not touch the original data + }) + + test('should create and execute a filter with booleans', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'registered'], + relation: '==', + value: 'true' + } + }) + assert.deepStrictEqual(query, 'filter(.user.registered == true)') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user1, user2]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a filter with null', () => { + const query = 'filter(exists(.user.extra))' + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user2]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a sort query in ascending direction', () => { + const query = createQuery(users, { + sort: { + path: ['user', 'age'], + direction: 'asc' + } + }) + assert.deepStrictEqual(query, 'sort(.user.age, "asc")') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user1, user2, user3]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a sort query in descending direction', () => { + const query = createQuery(users, { + sort: { + path: ['user', 'age'], + direction: 'desc' + } + }) + + assert.deepStrictEqual(query, 'sort(.user.age, "desc")') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user3, user2, user1]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a project query for a single property', () => { + const query = createQuery(users, { + projection: { + paths: [['user', 'name']] + } + }) + + assert.deepStrictEqual(query, 'map(.user.name)') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, ['Stuart', 'Kevin', 'Bob']) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a project query for a multiple properties', () => { + const query = createQuery(users, { + projection: { + paths: [['user', 'name'], ['_id']] + } + }) + + assert.deepStrictEqual(query, 'pick(.user.name, ._id)') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [ + { name: 'Stuart', _id: '1' }, + { name: 'Kevin', _id: '3' }, + { name: 'Bob', _id: '2' } + ]) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should create and execute a query with filter, sort and project', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'age'], + relation: '<=', + value: '7' + }, + sort: { + path: ['user', 'name'], + direction: 'asc' + }, + projection: { + paths: [['user', 'name']] + } + }) + + assert.deepStrictEqual( + query, + 'filter(.user.age <= 7)\n | sort(.user.name, "asc")\n | map(.user.name)' + ) + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, ['Bob', 'Stuart']) + assert.deepStrictEqual(users, originalUsers) // must not touch the original users + }) + + test('should work with alternative parsers and non-native JSON data types', () => { + const data = [new LosslessNumber('4'), new LosslessNumber('7'), new LosslessNumber('5')] + const query = createQuery(data, { + sort: { + path: [], + direction: 'asc' + } + }) + + const result = executeQuery(data, query, JSON) + assert.deepStrictEqual(result, [ + new LosslessNumber('4'), + new LosslessNumber('5'), + new LosslessNumber('7') + ]) + }) + + test('should throw an exception the query is not valid', () => { + assert.throws(() => { + const data = {} + const query = 'filter(' + executeQuery(data, query, JSON) + }, /SyntaxError: Value expected \(pos: 7\)/) + }) + + test('should return null when trying to use a non existing function', () => { + assert.throws(() => { + const data = {} + const query = 'foo(.bar)' + executeQuery(data, query, JSON) + }, /SyntaxError: Unknown function 'foo' \(pos: 4\)/) + }) + }) +}) diff --git a/src/lib/plugins/query/jsonQueryLanguage.ts b/src/lib/plugins/query/jsonQueryLanguage.ts new file mode 100644 index 00000000..5bb03585 --- /dev/null +++ b/src/lib/plugins/query/jsonQueryLanguage.ts @@ -0,0 +1,65 @@ +import { jsonquery, type JSONQuery, parse, stringify } from '@jsonquerylang/jsonquery' +import { parseString } from '$lib/utils/stringUtils.js' +import type { QueryLanguage, QueryLanguageOptions } from '$lib/types.js' +import type { JSONPath } from 'immutable-json-patch' + +const description = ` +

+ Enter a jsonquery function to filter, sort, or transform the data. + You can use functions like get, filter, + sort, pick, groupBy, uniq, etcetera. + Example query: filter(.age >= 18) +

+` + +export const jsonQueryLanguage: QueryLanguage = { + id: 'jsonquery', + name: 'jsonquery', + description, + createQuery, + executeQuery +} + +function createQuery(_json: unknown, queryOptions: QueryLanguageOptions): string { + const { filter, sort, projection } = queryOptions + const queryFunctions: JSONQuery[] = [] + + if (filter && filter.path && filter.relation && filter.value) { + queryFunctions.push([ + 'filter', + [ + getOperatorName(filter.relation), + getter(filter.path), + parseString(filter.value) as JSONQuery + ] + ]) + } + + if (sort && sort.path && sort.direction) { + queryFunctions.push(['sort', getter(sort.path), sort.direction === 'desc' ? 'desc' : 'asc']) + } + + if (projection && projection.paths) { + if (projection.paths.length > 1) { + queryFunctions.push(['pick', ...projection.paths.map(getter)]) + } else { + queryFunctions.push(['map', getter(projection.paths[0])]) + } + } + + return stringify(['pipe', ...queryFunctions]) +} + +function getter(path: JSONPath): ['get', ...path: JSONPath] { + return ['get', ...path] +} + +function executeQuery(json: unknown, query: string): unknown { + return query.trim() !== '' ? jsonquery(json, query) : json +} + +function getOperatorName(operator: string): string { + // a trick to get the name of the operator by parsing the operator in a temporary query + return (parse(`1 ${operator} 1`) as [string, number, number])[0] +} diff --git a/src/lib/plugins/query/jsonpathQueryLanguage.test.ts b/src/lib/plugins/query/jsonpathQueryLanguage.test.ts new file mode 100644 index 00000000..39db81b4 --- /dev/null +++ b/src/lib/plugins/query/jsonpathQueryLanguage.test.ts @@ -0,0 +1,169 @@ +import { test, describe } from 'vitest' +import assert from 'assert' +import { jsonpathQueryLanguage } from '$lib/plugins/query/jsonpathQueryLanguage' + +const { createQuery, executeQuery } = jsonpathQueryLanguage + +const user1 = { _id: '1', user: { name: 'Stuart', age: 6, registered: true } } +const user3 = { _id: '3', user: { name: 'Kevin', age: 8, registered: false } } +const user2 = { _id: '2', user: { name: 'Bob', age: 7, registered: true, extra: true } } + +const users = [user1, user3, user2] + +describe('jsonpathQueryLanguage', () => { + describe('createQuery and executeQuery', () => { + test('should create a and execute an empty query', () => { + const query = createQuery(users, {}) + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(query, '$') + assert.deepStrictEqual(result, [users]) + }) + + test('should create and execute a filter query for a nested property (one match)', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'name'], + relation: '==', + value: 'Bob' + } + }) + assert.deepStrictEqual(query, '$[?(@.user.name == "Bob")]') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user2]) + }) + + test('should create and execute a filter query for a nested property (multiple matches)', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'name'], + relation: '!=', + value: 'Bob' + } + }) + assert.deepStrictEqual(query, '$[?(@.user.name != "Bob")]') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user1, user3]) + }) + + test('should create and execute a filter query for a property with special characters in the name', () => { + const data = users.map((item) => ({ "user name'": item.user.name })) + + const query = createQuery(data, { + filter: { + path: ["user name'"], + relation: '==', + value: 'Bob' + } + }) + assert.deepStrictEqual(query, '$[?(@["user name\'"] == "Bob")]') + + const result = executeQuery(data, query, JSON) + assert.deepStrictEqual(result, [{ "user name'": 'Bob' }]) + }) + + test('should create and execute a filter query for the whole array item', () => { + const data = [2, 3, 1] + const query = createQuery(data, { + filter: { + path: [], + relation: '==', + value: '1' + } + }) + assert.deepStrictEqual(query, '$[?(@ == 1)]') + + const result = executeQuery(data, query, JSON) + assert.deepStrictEqual(result, [1]) + }) + + test('should create and execute a filter with booleans', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'registered'], + relation: '==', + value: 'true' + } + }) + assert.deepStrictEqual(query, '$[?(@.user.registered == true)]') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user1, user2]) + }) + + test('should create and execute a filter with null', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'extra'], + relation: '!=', + value: 'null' + } + }) + assert.deepStrictEqual(query, '$[?(@.user.extra != null)]') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, [user2]) + }) + + test('should throw an error when trying to sort (not supported by jsonpath)', () => { + assert.throws(() => { + createQuery(users, { + sort: { + path: ['user', 'age'], + direction: 'asc' + } + }) + }, /Sorting is not supported by jsonpath. Please clear the sorting fields/) + }) + + test('should create and execute a project query for a single property', () => { + const query = createQuery(users, { + projection: { + paths: [['user', 'name']] + } + }) + + assert.deepStrictEqual(query, '$[*].user.name') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, ['Stuart', 'Kevin', 'Bob']) + }) + + test('should throw an error when creating a project query for a multiple properties', () => { + assert.throws(() => { + createQuery(users, { + projection: { + paths: [['user', 'name'], ['_id']] + } + }) + }, /Error: Picking multiple fields is not supported by jsonpath. Please select only one field/) + }) + + test('should create and execute a query with filter and project', () => { + const query = createQuery(users, { + filter: { + path: ['user', 'age'], + relation: '<=', + value: '7' + }, + projection: { + paths: [['user', 'name']] + } + }) + + assert.deepStrictEqual(query, '$[?(@.user.age <= 7)].user.name') + + const result = executeQuery(users, query, JSON) + assert.deepStrictEqual(result, ['Stuart', 'Bob']) + }) + + test('should throw an exception when the query is no valid jsonpath expression', () => { + assert.throws(() => { + const data = {} + const query = '@bla bla bla' + executeQuery(data, query, JSON) + }, /TypeError: Unknown value type bla bla b/) + }) + }) +}) diff --git a/src/lib/plugins/query/jsonpathQueryLanguage.ts b/src/lib/plugins/query/jsonpathQueryLanguage.ts new file mode 100644 index 00000000..804384d5 --- /dev/null +++ b/src/lib/plugins/query/jsonpathQueryLanguage.ts @@ -0,0 +1,64 @@ +import { JSONPath as JSONPathPlus } from 'jsonpath-plus' +import { parseString } from '$lib/utils/stringUtils' +import type { QueryLanguage, QueryLanguageOptions } from '$lib/types' +import type { JSONPath } from 'immutable-json-patch' + +const description = ` +

+ Enter a jsonpath expression to filter, sort, or transform the data. +

` + +export const jsonpathQueryLanguage: QueryLanguage = { + id: 'jsonpath', + name: 'jsonpath', + description, + createQuery, + executeQuery +} + +function createQuery(_json: unknown, queryOptions: QueryLanguageOptions): string { + const { filter, sort, projection } = queryOptions + let expression = '$' + + if (filter && filter.path && filter.relation && filter.value) { + const filterValue = parseString(filter.value) + const filterValueStr = JSON.stringify(filterValue) + + expression += `[?(@${pathToString(filter.path)} ${filter.relation} ${filterValueStr})]` + } + + if (sort && sort.path && sort.direction) { + throw new Error('Sorting is not supported by jsonpath. Please clear the sorting fields') + } + + if (projection && projection.paths) { + if (projection.paths.length > 1) { + throw new Error( + 'Picking multiple fields is not supported by jsonpath. Please select only one field' + ) + } + + if (!expression.endsWith(']')) { + expression += '[*]' + } + expression += `${pathToString(projection.paths[0])}`.replace(/^\.\.\./, '..') + } + + return expression +} + +function executeQuery(json: unknown, path: string): unknown { + const output = JSONPathPlus({ json: json as JSON, path }) + return output !== undefined ? output : null +} + +function pathToString(path: JSONPath): JSONPath | string { + const lettersOnlyRegex = /^[A-z]+$/ + + return path + .map((prop) => { + return lettersOnlyRegex.test(prop) ? `.${prop}` : JSON.stringify([prop]) + }) + .join('') +} diff --git a/src/lib/plugins/query/lodashQueryLanguage.ts b/src/lib/plugins/query/lodashQueryLanguage.ts index 29e317cd..98623a97 100644 --- a/src/lib/plugins/query/lodashQueryLanguage.ts +++ b/src/lib/plugins/query/lodashQueryLanguage.ts @@ -1,9 +1,9 @@ import * as _ from 'lodash-es' import { last } from 'lodash-es' -import { createLodashPropertySelector, createPropertySelector } from '../../utils/pathUtils.js' -import { parseString } from '../../utils/stringUtils.js' -import type { QueryLanguage, QueryLanguageOptions } from '../../types.js' -import { isInteger } from '../../utils/typeUtils.js' +import { createLodashPropertySelector, createPropertySelector } from '$lib/utils/pathUtils.js' +import { parseString } from '$lib/utils/stringUtils.js' +import type { QueryLanguage, QueryLanguageOptions } from '$lib/types.js' +import { isInteger } from '$lib/utils/typeUtils.js' const description = `

diff --git a/src/lib/plugins/validator/createAjvValidator.test.ts b/src/lib/plugins/validator/createAjvValidator.test.ts index b23ab4a1..1af6f874 100644 --- a/src/lib/plugins/validator/createAjvValidator.test.ts +++ b/src/lib/plugins/validator/createAjvValidator.test.ts @@ -185,17 +185,6 @@ describe('createAjvValidator', () => { }, /schema is invalid: data\/type must be equal to one of the allowed values, data\/type must be array, data\/type must match a schema in anyOf/) }) - test('should throw an error when using the deprecated API', () => { - // Deprecation error for the API of v0.9.2 and older - assert.throws(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - createAjvValidator(schema, schemaDefinitions, { - allErrors: false - }) - }, /the signature of createAjvValidator is changed/) - }) - test('should support draft-07', () => { const schemaDraft07 = { $schema: 'http://json-schema.org/draft-07/schema#', diff --git a/src/lib/plugins/validator/createAjvValidator.ts b/src/lib/plugins/validator/createAjvValidator.ts index a58a98be..83eee1e0 100644 --- a/src/lib/plugins/validator/createAjvValidator.ts +++ b/src/lib/plugins/validator/createAjvValidator.ts @@ -29,18 +29,6 @@ export interface AjvValidatorOptions { * @return Returns a validation function */ export function createAjvValidator(options: AjvValidatorOptions): Validator { - // Deprecation error for the API of v0.9.2 and older - if (options.schema === undefined) { - throw new Error( - 'Deprecation warning: ' + - 'the signature of createAjvValidator is changed from ' + - 'createAjvValidator(schema, schemaDefinitions, ajvOptions) ' + - 'to ' + - 'createAjvValidator({ schema, schemaDefinitions, ajvOptions }). ' + - 'Please pass the arguments as an object instead of unnamed arguments.' - ) - } - let ajv = createAjvInstance(options) if (options.onCreateAjv !== undefined) { ajv = options.onCreateAjv(ajv) || ajv @@ -95,11 +83,10 @@ function normalizeAjvError(json: unknown, ajvError: ErrorObject): ValidationErro /** * Improve the error message of a JSON schema error, * for example list the available values of an enum. - * - * @param {Object} ajvError - * @return {Object} Returns the error with improved message */ -function improveAjvError(ajvError: ErrorObject) { +function improveAjvError(ajvError: ErrorObject): ErrorObject { + let message: string | undefined = undefined + if (ajvError.keyword === 'enum' && Array.isArray(ajvError.schema)) { let enums = ajvError.schema if (enums) { @@ -110,13 +97,13 @@ function improveAjvError(ajvError: ErrorObject) { enums = enums.slice(0, 5) enums.push(more) } - ajvError.message = 'should be equal to one of: ' + enums.join(', ') + message = 'should be equal to one of: ' + enums.join(', ') } } if (ajvError.keyword === 'additionalProperties') { - ajvError.message = 'should NOT have additional property: ' + ajvError.params.additionalProperty + message = 'should NOT have additional property: ' + ajvError.params.additionalProperty } - return ajvError + return message ? { ...ajvError, message } : ajvError } diff --git a/src/lib/plugins/value/components/ColorPicker.scss b/src/lib/plugins/value/components/ColorPicker.scss index 40e6b08c..01eede97 100644 --- a/src/lib/plugins/value/components/ColorPicker.scss +++ b/src/lib/plugins/value/components/ColorPicker.scss @@ -7,7 +7,7 @@ height: $color-picker-button-size; box-sizing: border-box; padding: 0; - margin: 2px 0 0; + margin: 2px 0 0 $padding-half; display: inline-flex; vertical-align: top; diff --git a/src/lib/plugins/value/components/EditableValue.svelte b/src/lib/plugins/value/components/EditableValue.svelte index 7f580a9c..ef9b990a 100644 --- a/src/lib/plugins/value/components/EditableValue.svelte +++ b/src/lib/plugins/value/components/EditableValue.svelte @@ -1,26 +1,31 @@ @@ -21,7 +22,7 @@ let bindValue: unknown = value $: bindValue = value - function applyFocus(selection: JSONSelection | null) { + function applyFocus(selection: JSONSelection | undefined) { if (selection) { if (refSelect) { refSelect.focus() @@ -54,7 +55,7 @@

diff --git a/src/routes/examples/custom_dynamic_styling/+page.svelte b/src/routes/examples/custom_dynamic_styling/+page.svelte index 690d4e3d..23151e47 100644 --- a/src/routes/examples/custom_dynamic_styling/+page.svelte +++ b/src/routes/examples/custom_dynamic_styling/+page.svelte @@ -22,6 +22,8 @@ if (value === true || value === false) { return 'custom-class-boolean' } + + return undefined } diff --git a/svelte.config.js b/svelte.config.js index 5ebb3541..cc0f45b1 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,5 +1,5 @@ import adapter from '@sveltejs/adapter-auto' -import sveltePreprocess from 'svelte-preprocess' +import { sveltePreprocess } from 'svelte-preprocess' /** @type {import('@sveltejs/kit').Config} */ const config = { diff --git a/tools/createVanillaPackageJson.js b/tools/createVanillaPackageJson.js index 2b5ed388..d528a16b 100644 --- a/tools/createVanillaPackageJson.js +++ b/tools/createVanillaPackageJson.js @@ -9,11 +9,15 @@ const vanillaPackageFolder = getAbsolutePath(import.meta.url, '..', 'package-van const pkg = JSON.parse(String(readFileSync(getAbsolutePath(import.meta.url, '..', 'package.json')))) -// We add svelte here: this is needed to export the TypeScript types -const usedDependencyNames = [...getVanillaDependencies(), 'svelte'] +// We move peerDependencies to dependencies to make the package standalone. +// This is necessary for the "svelte" dependency, which is needed to export the TypeScript types +const usedDependencyNames = [ + ...getVanillaDependencies(), + ...Object.keys(pkg.peerDependencies) +].sort() const usedDependencies = usedDependencyNames.reduce((deps, name) => { - deps[name] = pkg.dependencies[name] + deps[name] = pkg.dependencies[name] || pkg.peerDependencies[name] return deps }, {}) @@ -21,7 +25,8 @@ const vanillaPackage = { ...pkg, name: 'vanilla-jsoneditor', scripts: {}, - dependencies: usedDependencies, // needed for the TypeScript types + dependencies: usedDependencies, + peerDependencies: {}, // all peer dependencies are moved to dependencies devDependencies: {}, svelte: undefined, browser: './standalone.js', diff --git a/tools/develop-vanilla.html b/tools/develop-vanilla.html index c693bf6a..19c9ec65 100644 --- a/tools/develop-vanilla.html +++ b/tools/develop-vanilla.html @@ -108,7 +108,8 @@