diff --git a/HISTORY.md b/HISTORY.md
index 6770b64..0fcaa20 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -3,6 +3,14 @@
https://github.com/josdejong/jsoneditor
+## not yet published, version 8.5.0
+
+- Implemented support for customizing the query language used in the
+ Transform modal. New options `createQuery`, `executeQuery`, and
+ `queryDescription` are available for this now. An example is available
+ in `examples/23_custom_query_language.html`. See #857, #871.
+
+
## 2020-01-25, version 8.4.1
- Fix `console.log` in production code. Oopsie.
diff --git a/docs/api.md b/docs/api.md
index c2d1317..6310649 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -589,6 +589,55 @@ Constructs a new JSONEditor.
- `{Number} maxVisibleChilds`
Number of children allowed for a given node before the "show more / show all" message appears (in 'tree', 'view', or 'form' modes). `100` by default.
+
+- `{ function(json: JSON, queryOptions: QueryOptions) -> string } createQuery`
+
+ Create a query string based on query options filled in the Transform Wizard in the Transform modal.
+ Normally used in combination with `executeQuery`.
+ The input for the function are the entered query options and the current JSON, and the output
+ must be a string containing the query. This query will be executed using `executeQuery`.
+
+ The query options have the following structure:
+
+ ```
+ interface QueryOptions {
+ filter?: {
+ field: string | '@'
+ relation: '==' | '!=' | '<' | '<=' | '>' | '>='
+ value: string
+ }
+ sort?: {
+ field: string | '@'
+ direction: 'asc' | 'desc'
+ }
+ projection?: {
+ fields: string[]
+ }
+ }
+ ```
+
+ Note that there is a special case `'@'` for `filter.field` and `sort.field`.
+ It means that the field itself is selected, for example when having an array containing numbers.
+
+ A usage example can be found in `examples/23_custom_query_language.html`.
+
+
+- `{ function(json: JSON, query: string) -> JSON } executeQuery`
+
+ Replace the build-in query language used in the Transform modal with a custom language.
+ Normally used in combination with `createQuery`.
+ The input for the function is the current JSON and a query string, and output must be the transformed JSON.
+
+ A usage example can be found in `examples/23_custom_query_language.html`.
+
+- `{string} queryDescription`
+
+ A text description displayed on top of the Transform modal.
+ Can be used to explain a custom query language implemented via `createQuery` and `executeQuery`.
+ The text can contain HTML code like a link to a web page.
+
+ A usage example can be found in `examples/23_custom_query_language.html`.
+
### Methods
diff --git a/examples/23_custom_query_language.html b/examples/23_custom_query_language.html
new file mode 100644
index 0000000..33c21e2
--- /dev/null
+++ b/examples/23_custom_query_language.html
@@ -0,0 +1,123 @@
+
+
+
+ JSONEditor | Custom query language
+
+
+
+
+
+
+
+
+
+ This demo shows how to configure a custom query language.
+ Click on the "Transform" button and try it out.
+
+
+ This basic example uses lodash functions filter
, sort
, and pick
,
+ but you can run any JavaScript code.
+
+
+ WARNING: this example uses new Function()
which can be dangerous when executed with arbitrary code.
+ Don't use it in production.
+
+
+
+
+
+
diff --git a/src/js/JSONEditor.js b/src/js/JSONEditor.js
index 6fd4482..305b3a3 100644
--- a/src/js/JSONEditor.js
+++ b/src/js/JSONEditor.js
@@ -184,7 +184,8 @@ JSONEditor.VALID_OPTIONS = [
'escapeUnicode', 'history', 'search', 'mode', 'modes', 'name', 'indentation',
'sortObjectKeys', 'navigationBar', 'statusBar', 'mainMenuBar', 'languages', 'language', 'enableSort', 'enableTransform',
'maxVisibleChilds', 'onValidationError',
- 'modalAnchor', 'popupAnchor'
+ 'modalAnchor', 'popupAnchor',
+ 'createQuery', 'executeQuery', 'queryDescription'
]
/**
diff --git a/src/js/Node.js b/src/js/Node.js
index af85dfa..98366fe 100644
--- a/src/js/Node.js
+++ b/src/js/Node.js
@@ -1,6 +1,5 @@
'use strict'
-import jmespath from 'jmespath'
import naturalSort from 'javascript-natural-sort'
import { createAbsoluteAnchor } from './createAbsoluteAnchor'
import { ContextMenu } from './ContextMenu'
@@ -3331,7 +3330,7 @@ export class Node {
// apply the JMESPath query
const oldValue = this.getValue()
- const newValue = jmespath.search(oldValue, query)
+ const newValue = this.editor.options.executeQuery(oldValue, query)
this.setValue(newValue)
const newInternalValue = this.getInternalValue()
@@ -3883,12 +3882,16 @@ export class Node {
* Show transform modal
*/
showTransformModal () {
- const node = this
+ const { modalAnchor, createQuery, executeQuery, queryDescription } = this.editor.options
+ const json = this.getValue()
- const anchor = this.editor.options.modalAnchor || DEFAULT_MODAL_ANCHOR
- const json = node.getValue()
- showTransformModal(anchor, json, query => {
- node.transform(query)
+ showTransformModal({
+ anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
+ json,
+ queryDescription, // can be undefined
+ createQuery,
+ executeQuery,
+ onTransform: query => { this.transform(query) }
})
}
diff --git a/src/js/jmespathQuery.js b/src/js/jmespathQuery.js
new file mode 100644
index 0000000..a9f6b23
--- /dev/null
+++ b/src/js/jmespathQuery.js
@@ -0,0 +1,75 @@
+import jmespath from 'jmespath'
+import { get, parsePath, parseString } from './util'
+
+/**
+ * Build a JMESPath query based on query options coming from the wizard
+ * @param {JSON} json The JSON document for which to build the query.
+ * Used for context information like determining
+ * the type of values (string or number)
+ * @param {QueryOptions} queryOptions
+ * @return {string} Returns a query (as string)
+ */
+export function createQuery (json, queryOptions) {
+ const { sort, filter, projection } = queryOptions
+ let query = ''
+
+ if (filter) {
+ const examplePath = filter.field !== '@'
+ ? ['0'].concat(parsePath('.' + filter.field))
+ : ['0']
+ const exampleValue = get(json, examplePath)
+ const value1 = typeof exampleValue === 'string'
+ ? filter.value
+ : parseString(filter.value)
+
+ query += '[? ' +
+ filter.field + ' ' +
+ filter.relation + ' ' +
+ '`' + JSON.stringify(value1) + '`' +
+ ']'
+ } else {
+ query += Array.isArray(json)
+ ? '[*]'
+ : '@'
+ }
+
+ if (sort) {
+ if (sort.direction === 'desc') {
+ query += ' | reverse(sort_by(@, &' + sort.field + '))'
+ } else {
+ query += ' | sort_by(@, &' + sort.field + ')'
+ }
+ }
+
+ if (projection) {
+ if (query[query.length - 1] !== ']') {
+ query += ' | [*]'
+ }
+
+ if (projection.fields.length === 1) {
+ query += '.' + projection.fields[0]
+ } else if (projection.fields.length > 1) {
+ query += '.{' +
+ projection.fields.map(value => {
+ const parts = value.split('.')
+ const last = parts[parts.length - 1]
+ return last + ': ' + value
+ }).join(', ') +
+ '}'
+ } else { // values.length === 0
+ // ignore
+ }
+ }
+
+ return query
+}
+
+/**
+ * Execute a JMESPath query
+ * @param {JSON} json
+ * @param {string} query
+ * @return {JSON} Returns the transformed JSON
+ */
+export function executeQuery (json, query) {
+ return jmespath.search(json, query)
+}
diff --git a/src/js/previewmode.js b/src/js/previewmode.js
index dd74985..7b3c6be 100644
--- a/src/js/previewmode.js
+++ b/src/js/previewmode.js
@@ -1,6 +1,5 @@
'use strict'
-import jmespath from 'jmespath'
import { translate } from './i18n'
import { ModeSwitcher } from './ModeSwitcher'
import { ErrorTable } from './ErrorTable'
@@ -23,6 +22,7 @@ import {
sortObjectKeys
} from './util'
import { History } from './History'
+import { createQuery, executeQuery } from './jmespathQuery'
const textmode = textModeMixins[0].mixin
@@ -44,6 +44,8 @@ previewmode.create = function (container, options = {}) {
options.mainMenuBar = options.mainMenuBar !== false
options.enableSort = options.enableSort !== false
options.enableTransform = options.enableTransform !== false
+ options.createQuery = options.createQuery || createQuery
+ options.executeQuery = options.executeQuery || executeQuery
this.options = options
@@ -385,18 +387,24 @@ previewmode._showSortModal = function () {
* @private
*/
previewmode._showTransformModal = function () {
- const me = this
-
this.executeWithBusyMessage(() => {
- const anchor = me.options.modalAnchor || DEFAULT_MODAL_ANCHOR
- const json = me.get()
- me._renderPreview() // update array count
+ const { createQuery, executeQuery, modalAnchor, queryDescription } = this.options
+ const json = this.get()
- showTransformModal(anchor, json, query => {
- me.executeWithBusyMessage(() => {
- const updatedJson = jmespath.search(json, query)
- me._setAndFireOnChange(updatedJson)
- }, 'transforming...')
+ this._renderPreview() // update array count
+
+ showTransformModal({
+ anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
+ json,
+ queryDescription, // can be undefined
+ createQuery,
+ executeQuery,
+ onTransform: query => {
+ this.executeWithBusyMessage(() => {
+ const updatedJson = executeQuery(json, query)
+ this._setAndFireOnChange(updatedJson)
+ }, 'transforming...')
+ }
})
}, 'parsing...')
}
diff --git a/src/js/showTransformModal.js b/src/js/showTransformModal.js
index 2482b1f..5136c82 100644
--- a/src/js/showTransformModal.js
+++ b/src/js/showTransformModal.js
@@ -1,28 +1,43 @@
-import jmespath from 'jmespath'
import picoModal from 'picomodal'
import Selectr from './assets/selectr/selectr'
import { translate } from './i18n'
import { stringifyPartial } from './jsonUtils'
-import { getChildPaths, get, parsePath, parseString, debounce } from './util'
+import { getChildPaths, debounce } from './util'
import { MAX_PREVIEW_CHARACTERS } from './constants'
+const DEFAULT_DESCRIPTION =
+ 'Enter a JMESPath query to filter, sort, or transform the JSON data. ' +
+ 'To learn JMESPath, go to the interactive tutorial .'
+
/**
* Show advanced filter and transform modal using JMESPath
- * @param {HTMLElement} container The container where to center
- * the modal and create an overlay
- * @param {JSON} json The json data to be transformed
- * @param {function} onTransform Callback invoked with the created
- * query as callback
+ * @param {Object} params
+ * @property {HTMLElement} container The container where to center
+ * the modal and create an overlay
+ * @property {JSON} json The json data to be transformed
+ * @property {string} [queryDescription] Optional custom description explaining
+ * the transform functionality
+ * @property {function} createQuery Function called to create a query
+ * from the wizard form
+ * @property {function} executeQuery Execute a query for the preview pane
+ * @property {function} onTransform Callback invoked with the created
+ * query as callback
*/
-export function showTransformModal (container, json, onTransform) {
+export function showTransformModal (
+ {
+ container,
+ json,
+ queryDescription = DEFAULT_DESCRIPTION,
+ createQuery,
+ executeQuery,
+ onTransform
+ }
+) {
const value = json
const content = '' +
'' +
- '' +
- 'Enter a JMESPath query to filter, sort, or transform the JSON data. ' +
- 'To learn JMESPath, go to the interactive tutorial .' +
- '
' +
+ '' + queryDescription + '
' +
'' + translate('transformWizardLabel') + '
' +
'' +
'
' +
@@ -176,8 +191,6 @@ export function showTransformModal (container, json, onTransform) {
}
}
- query.value = Array.isArray(value) ? '[*]' : '@'
-
function preprocessPath (path) {
return (path === '')
? '@'
@@ -186,69 +199,9 @@ export function showTransformModal (container, json, onTransform) {
: path
}
- function generateQueryFromWizard () {
- if (filterField.value && filterRelation.value && filterValue.value) {
- const field1 = filterField.value
- const examplePath = field1 !== '@'
- ? ['0'].concat(parsePath('.' + field1))
- : ['0']
- const exampleValue = get(value, examplePath)
- const value1 = typeof exampleValue === 'string'
- ? filterValue.value
- : parseString(filterValue.value)
-
- query.value = '[? ' +
- field1 + ' ' +
- filterRelation.value + ' ' +
- '`' + JSON.stringify(value1) + '`' +
- ']'
- } else {
- query.value = '[*]'
- }
-
- if (sortField.value && sortOrder.value) {
- const field2 = sortField.value
- if (sortOrder.value === 'desc') {
- query.value += ' | reverse(sort_by(@, &' + field2 + '))'
- } else {
- query.value += ' | sort_by(@, &' + field2 + ')'
- }
- }
-
- if (selectFields.value) {
- const values = []
- for (let i = 0; i < selectFields.options.length; i++) {
- if (selectFields.options[i].selected) {
- const selectedValue = selectFields.options[i].value
- values.push(selectedValue)
- }
- }
-
- if (query.value[query.value.length - 1] !== ']') {
- query.value += ' | [*]'
- }
-
- if (values.length === 1) {
- query.value += '.' + values[0]
- } else if (values.length > 1) {
- query.value += '.{' +
- values.map(value => {
- const parts = value.split('.')
- const last = parts[parts.length - 1]
- return last + ': ' + value
- }).join(', ') +
- '}'
- } else { // values.length === 0
- // ignore
- }
- }
-
- debouncedUpdatePreview()
- }
-
function updatePreview () {
try {
- const transformed = jmespath.search(value, query.value)
+ const transformed = executeQuery(value, query.value)
preview.className = 'jsoneditor-transform-preview'
preview.value = stringifyPartial(transformed, 2, MAX_PREVIEW_CHARACTERS)
@@ -261,10 +214,61 @@ export function showTransformModal (container, json, onTransform) {
}
}
- var debouncedUpdatePreview = debounce(updatePreview, 300)
+ const debouncedUpdatePreview = debounce(updatePreview, 300)
+
+ function tryCreateQuery (json, queryOptions) {
+ try {
+ query.value = createQuery(json, queryOptions)
+ ok.disabled = false
+
+ debouncedUpdatePreview()
+ } catch (err) {
+ const message = 'Error: an error happened when executing "createQuery": ' + (err.message || err.toString())
+
+ query.value = ''
+ ok.disabled = true
+
+ preview.className = 'jsoneditor-transform-preview jsoneditor-error'
+ preview.value = message
+ }
+ }
+
+ function generateQueryFromWizard () {
+ const queryOptions = {}
+
+ if (filterField.value && filterRelation.value && filterValue.value) {
+ queryOptions.filter = {
+ field: filterField.value,
+ relation: filterRelation.value,
+ value: filterValue.value
+ }
+ }
+
+ if (sortField.value && sortOrder.value) {
+ queryOptions.sort = {
+ field: sortField.value,
+ direction: sortOrder.value
+ }
+ }
+
+ if (selectFields.value) {
+ const fields = []
+ for (let i = 0; i < selectFields.options.length; i++) {
+ if (selectFields.options[i].selected) {
+ const selectedField = selectFields.options[i].value
+ fields.push(selectedField)
+ }
+ }
+
+ queryOptions.projection = {
+ fields
+ }
+ }
+
+ tryCreateQuery(json, queryOptions)
+ }
query.oninput = debouncedUpdatePreview
- debouncedUpdatePreview()
ok.onclick = event => {
event.preventDefault()
@@ -275,6 +279,9 @@ export function showTransformModal (container, json, onTransform) {
onTransform(query.value)
}
+ // initialize with empty query
+ tryCreateQuery(json, {})
+
setTimeout(() => {
query.select()
query.focus()
diff --git a/src/js/textmode.js b/src/js/textmode.js
index 195d966..0d9a300 100644
--- a/src/js/textmode.js
+++ b/src/js/textmode.js
@@ -1,7 +1,6 @@
'use strict'
import ace from './ace'
-import jmespath from 'jmespath'
import { translate } from './i18n'
import { ModeSwitcher } from './ModeSwitcher'
import { ErrorTable } from './ErrorTable'
@@ -26,6 +25,7 @@ import {
} from './util'
import { DEFAULT_MODAL_ANCHOR } from './constants'
import { tryRequireThemeJsonEditor } from './tryRequireThemeJsonEditor'
+import { createQuery, executeQuery } from './jmespathQuery'
// create a mixin with the functions for text mode
const textmode = {}
@@ -47,6 +47,8 @@ textmode.create = function (container, options = {}) {
options.mainMenuBar = options.mainMenuBar !== false
options.enableSort = options.enableSort !== false
options.enableTransform = options.enableTransform !== false
+ options.createQuery = options.createQuery || createQuery
+ options.executeQuery = options.executeQuery || executeQuery
this.options = options
@@ -425,12 +427,19 @@ textmode._showSortModal = function () {
* @private
*/
textmode._showTransformModal = function () {
- const me = this
- const anchor = this.options.modalAnchor || DEFAULT_MODAL_ANCHOR
+ const { modalAnchor, createQuery, executeQuery, queryDescription } = this.options
const json = this.get()
- showTransformModal(anchor, json, query => {
- const updatedJson = jmespath.search(json, query)
- me.set(updatedJson)
+
+ showTransformModal({
+ anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
+ json,
+ queryDescription, // can be undefined
+ createQuery,
+ executeQuery,
+ onTransform: query => {
+ const updatedJson = executeQuery(json, query)
+ this.set(updatedJson)
+ }
})
}
diff --git a/src/js/treemode.js b/src/js/treemode.js
index d8329dd..89e03b5 100644
--- a/src/js/treemode.js
+++ b/src/js/treemode.js
@@ -29,6 +29,7 @@ import {
} from './util'
import { autocomplete } from './autocomplete'
import { setLanguage, setLanguages, translate } from './i18n'
+import { createQuery, executeQuery } from './jmespathQuery'
// create a mixin with the functions for tree mode
const treemode = {}
@@ -156,6 +157,8 @@ treemode._setOptions = function (options) {
},
timestampTag: true,
timestampFormat: null,
+ createQuery,
+ executeQuery,
onEvent: null,
enableSort: true,
enableTransform: true
diff --git a/src/js/types.js b/src/js/types.js
new file mode 100644
index 0000000..3f6b205
--- /dev/null
+++ b/src/js/types.js
@@ -0,0 +1,25 @@
+
+/**
+ * @typedef {object} QueryOptions
+ * @property {FilterOptions} [filter]
+ * @property {SortOptions} [sort]
+ * @property {ProjectionOptions} [projection]
+ */
+
+/**
+ * @typedef {object} FilterOptions
+ * @property {string} field
+ * @property {string} relation Can be '==', '<', etc
+ * @property {string} value
+ */
+
+/**
+ * @typedef {object} SortOptions
+ * @property {string} field
+ * @property {string} direction Can be 'asc' or 'desc'
+ */
+
+/**
+ * @typedef {object} ProjectionOptions
+ * @property {string[]} fields
+ */