├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── AstEditor.tsx ├── Consts.tsx ├── Theme.ts ├── Types.ts ├── example │ ├── Flowchart.tsx │ └── PurchaseEvent.schema.js ├── index.tsx ├── react-app-env.d.ts ├── schema │ ├── PathSuggester.ts │ └── SchemaProvider.ts ├── specs │ ├── BinaryFlattening.feature │ ├── EditorToggling.feature │ ├── PathSuggestions.feature │ ├── TypeCoercion.feature │ └── TypeValidation.feature ├── theme │ ├── ButtonHelp.tsx │ ├── DefaultMathTheme.tsx │ ├── DefaultTheme.tsx │ └── PathEditor.tsx └── util │ ├── DefaultProvider.ts │ └── Validators.tsx └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | .rts2_cache_cjs 5 | .rts2_cache_umd 6 | .rts2_cache_es 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.4.3] - 2023-04-20 9 | 10 | ### Changed 11 | - Updated license copyright to be in line with SaaSquatch open-source policy. 12 | 13 | ## [0.4.2] - 2022-09-21 14 | 15 | ### Fixed 16 | 17 | - getPaths now properly escapes path strings with dashes 18 | - getPaths now returns results for schema properties with multiple types 19 | 20 | ## [0.4.1] - 2021-10-28 21 | 22 | ### Fixed 23 | 24 | - Fixed a race condition that would cause a crash if schema was undefined 25 | 26 | ## [0.4.0] - 2021-05-12 27 | 28 | ### Fixed 29 | 30 | - React added as a peerDependency to prevent invalid hook call errors 31 | 32 | ## [0.3.10] - 2020-12-08 33 | 34 | ### Updated 35 | 36 | - defaultIsValidBasicExpression changed to allow more complex jsonata strings 37 | 38 | ## [0.3.8] - 2020-03-11 39 | 40 | ### Added 41 | 42 | - Math support added to text fields 43 | 44 | [unreleased]: https://github.com/jsonata-ui/jsonata-visual-editor/compare/jsonata-visual-editor@0.4.3...HEAD 45 | [0.4.3]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/jsonata-visual-editor%400.4.3 46 | [0.4.2]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/jsonata-visual-editor%400.4.2 47 | [0.4.1]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/jsonata-visual-editor%400.4.1 48 | [0.3.8]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.8 49 | [0.3.7]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.7 50 | [0.3.6]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.6 51 | [0.3.5]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.5 52 | [0.3.4]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.4 53 | [0.3.3]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.3 54 | [0.3.2]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.2 55 | [0.3.0]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.3.0 56 | [0.2.0]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.2.0 57 | [0.1.3]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.1.3 58 | [0.1.2]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.1.2 59 | [0.1.1]: https://github.com/saasquatch/jsonata-visual-editor/releases/tag/v0.1.1 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ReferralSaaSquatch.com, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonata-visual-editor 2 | 3 | A visual expression builder for [jsonata](http://jsonata.org/); 4 | 5 | Demo: https://codesandbox.io/s/jsonata-visual-editor-es4p2 6 | 7 | Uses cases: 8 | 9 | - Conditionals `a < 10 ? "Tier 1" : a < 20 ? "Tier 2" : "Tier 3"` 10 | - Logic builders `user.name = "Logan" and age > 10` 11 | - JSON Mapping `{ "name": user.firstName & " " & user.lastName, "ltv": $sum(user.purchases.total) }` 12 | 13 | 14 | ## Sponsors 15 | 16 | Sponsored by [SaaSquatch](http://saasquatch.com). Loyalty, point and referral programs for forward-looking companies. 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsonata-visual-editor", 3 | "version": "0.4.3", 4 | "license": "MIT", 5 | "author": "ReferralSaaSquatch.com, Inc.", 6 | "description": "Visual editor for JSONata expressions. Themeable to your needs, default theme is Boostrap 4", 7 | "keywords": [ 8 | "jsonata" 9 | ], 10 | "source": "src/AstEditor.tsx", 11 | "main": "dist/AstEditor.js", 12 | "types": "dist/AstEditor.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "dependencies": { 17 | "jsonata": "1.7.0", 18 | "jsonata-ui-core": "^1.7.12", 19 | "unstated-next": "1.1.0" 20 | }, 21 | "peerDependencies": { 22 | "react": ">=16.8.6" 23 | }, 24 | "devDependencies": { 25 | "@ant-design/icons": "2.1.1", 26 | "@ant-design/icons-react": "2.0.1", 27 | "@mrblenny/react-flow-chart": "0.0.8", 28 | "@types/json-schema": "7.0.3", 29 | "microbundle": "0.11.0", 30 | "react": "16.8.6", 31 | "react-bootstrap": "1.0.0-beta.14", 32 | "react-dom": "16.8.6", 33 | "react-scripts": "3.2.0", 34 | "react-select": "3.0.8", 35 | "styled-components": "4.4.0", 36 | "typescript": "3.4.5" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "microbundle -f cjs", 41 | "dev": "microbundle watch", 42 | "test": "react-scripts test --env=jsdom", 43 | "eject": "react-scripts eject" 44 | }, 45 | "browserslist": [ 46 | ">0.2%", 47 | "not dead", 48 | "not ie <= 11", 49 | "not op_mini all" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 | 34 |
35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/AstEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import jsonata from "jsonata"; 3 | 4 | import { createContainer } from "unstated-next"; 5 | 6 | import { 7 | serializer, 8 | BinaryNode, 9 | PathNode, 10 | LiteralNode, 11 | BlockNode, 12 | ConditionNode, 13 | VariableNode, 14 | ObjectUnaryNode, 15 | ArrayUnaryNode, 16 | ApplyNode, 17 | FunctionNode, 18 | BindNode, 19 | NameNode 20 | } from "jsonata-ui-core"; 21 | import { 22 | ParsingState, 23 | NodeEditorProps, 24 | DefaultProvider, 25 | Mode, 26 | Modes, 27 | OnChange, 28 | AST, 29 | Container 30 | } from "./Types"; 31 | import * as _consts from "./Consts"; 32 | import { StandardDefaultProvider } from "./util/DefaultProvider"; 33 | 34 | import * as Types from "./Types"; 35 | import { Theme, MathPart } from "./Theme"; 36 | import _paths from "./schema/PathSuggester"; 37 | import * as SchemaProvider from "./schema/SchemaProvider"; 38 | // re-export types for theming purposes 39 | export * from "./Theme"; 40 | export * from "./Types"; 41 | 42 | export { SchemaProvider }; 43 | export const Consts = _consts; 44 | export const PathSuggester = _paths; 45 | 46 | function useEditorContext(initialState: Container | undefined): Container { 47 | if (initialState === undefined) { 48 | throw new Error("initialState is required!"); 49 | } 50 | const { 51 | schemaProvider, 52 | theme, 53 | boundVariables, 54 | defaultProvider 55 | } = initialState; 56 | 57 | return { schemaProvider, theme, boundVariables, defaultProvider }; 58 | } 59 | 60 | export const Context = createContainer(useEditorContext); 61 | 62 | // See all the AST types: https://github.com/mtiller/jsonata/blob/ts-2.0/src/parser/ast.ts 63 | // const NestedPathValue = jsonata(`$join(steps.value,".")`); 64 | 65 | export const isNumberNode = (n: AST) => n.type === "number"; 66 | export const isPathNode = (n: AST) => n.type === "path"; 67 | export const isMath = (n: AST): n is BinaryNode => n.type === "binary" && Object.keys(Consts.mathOperators).includes(n.value); 68 | 69 | type State = 70 | | { 71 | mode: "NodeMode"; 72 | ast: AST; 73 | toggleBlock: string | null; 74 | } 75 | | { 76 | mode: "IDEMode"; 77 | ast?: AST; 78 | toggleBlock: string | null; 79 | }; 80 | 81 | export function Editor(props: Types.EditorProps) { 82 | const { isValidBasicExpression = defaultIsValidBasicExpression, text } = props; 83 | const initialState = (): State => { 84 | try { 85 | let newAst = jsonata(props.text).ast() as AST; 86 | const toggleBlock = isValidBasicExpression(newAst); 87 | return { 88 | toggleBlock, 89 | mode: (toggleBlock === null ? Modes.NodeMode : Modes.IDEMode) as Mode 90 | } as State; 91 | } catch (e) { 92 | return { 93 | toggleBlock: "Parsing error with expression", 94 | mode: "IDEMode" 95 | } as State; 96 | } 97 | }; 98 | 99 | const [state, setState] = useState(initialState); 100 | 101 | const [onChangeMemo] = useUpDownEffect(props.text, props.onChange, () => 102 | setState(initialState()) 103 | ); 104 | 105 | function toggleMode() { 106 | if (state.mode === Modes.NodeMode) { 107 | setState({ 108 | ...state, 109 | mode: "IDEMode" 110 | }); 111 | } else { 112 | // TODO: Need AST from IDE 113 | const ast = jsonata(props.text).ast() as AST; 114 | setState({ 115 | ...state, 116 | ast: ast, 117 | mode: "NodeMode" 118 | }); 119 | } 120 | } 121 | 122 | const { schema, schemaProvider, theme, defaultProvider = {} } = props; 123 | const defaults: DefaultProvider = { 124 | ...StandardDefaultProvider, 125 | ...defaultProvider 126 | }; 127 | const provider = schema 128 | ? SchemaProvider.makeSchemaProvider(schema) 129 | : schemaProvider; 130 | 131 | const astChange = (newAst: AST) => { 132 | const text = serializer(newAst); 133 | onChangeMemo(text); 134 | }; 135 | const setToggleBlock = (text: string | null) => { 136 | setState({ 137 | ...state, 138 | toggleBlock: text 139 | }); 140 | }; 141 | const { toggleBlock, mode } = state; 142 | let editor = 143 | mode === Modes.NodeMode ? ( 144 | 145 | ) : ( 146 | 152 | ); 153 | 154 | return ( 155 | 162 | 168 | 169 | ); 170 | } 171 | 172 | export function defaultIsValidBasicExpression(ast: AST): string | null { 173 | const advancedOnly = jsonata(`**[type = "block" or type = "lambda" or type = "transform" or (type = "binary" and value = "&")]`); 174 | try { 175 | if(advancedOnly.evaluate(ast)) { 176 | return "Can't use basic editor for advanced expressions. Try a simpler expression."; 177 | } 178 | } catch (e) { 179 | return "Failed to evaluate expression"; 180 | } 181 | return null;; 182 | } 183 | 184 | function NodeEditor(props: NodeEditorProps): JSX.Element { 185 | const { ast, ...rest } = props; 186 | if (ast.type === "binary") { 187 | return ; 188 | } else if (ast.type === "path") { 189 | return ; 190 | } else if ( 191 | ast.type === "number" || 192 | ast.type === "value" || 193 | ast.type === "string" 194 | ) { 195 | return ; 196 | } else if (ast.type === "block") { 197 | return ; 198 | } else if (ast.type === "condition") { 199 | return ; 200 | } else if (ast.type === "variable") { 201 | return ; 202 | } else if (ast.type === "bind") { 203 | return ; 204 | } else if (ast.type === "apply") { 205 | return ; 206 | } else if (ast.type === "function") { 207 | return ; 208 | } else if (ast.type === "unary" && ast.value === "{") { 209 | return ; 210 | } else if (ast.type === "unary" && ast.value === "[") { 211 | return ; 212 | } else { 213 | throw new Error("Unsupported node type: " + props.ast.type); 214 | } 215 | } 216 | 217 | /** 218 | * 219 | * @param value the prop value 220 | * @param onChange the onChange callback 221 | * @param effect the effect to call when the downward value changes 222 | */ 223 | function useUpDownEffect( 224 | value: T, 225 | onChange: (v: T) => void, 226 | effect: React.EffectCallback 227 | ) { 228 | const [upwardValue, setUpward] = useState(null); 229 | useEffect(() => { 230 | if (value !== upwardValue) { 231 | setUpward(value); 232 | effect(); 233 | } 234 | }, [value]); 235 | const upWardChange = (up: T) => { 236 | setUpward(up); 237 | onChange(up); 238 | }; 239 | return [upWardChange]; 240 | } 241 | 242 | type IDEEditorProps = { 243 | text: string; 244 | onChange: (text: string) => void; 245 | setToggleBlock: (text: string | null) => void; 246 | isValidBasicExpression(ast: AST): string | null; 247 | }; 248 | export function IDEEditor({ 249 | text, 250 | onChange, 251 | setToggleBlock, 252 | isValidBasicExpression 253 | }: IDEEditorProps): JSX.Element { 254 | const { theme } = Context.useContainer(); 255 | const [parsing, setParsing] = useState({ 256 | inProgress: false, 257 | error: "" 258 | }); 259 | 260 | const [onChangeMemo] = useUpDownEffect(text, onChange, doParsing); 261 | 262 | const onChangeAst = (newValue: AST) => { 263 | const toggleBlock = isValidBasicExpression(newValue); 264 | setToggleBlock(toggleBlock); 265 | }; 266 | const setError = (e?: string) => { 267 | setToggleBlock(e ? "Can't switch modes while there is an error." : null); 268 | }; 269 | 270 | function doParsing(newText?: string) { 271 | // Start parsing asynchronously 272 | setParsing({ 273 | inProgress: true, 274 | error: undefined 275 | }); 276 | let newAst: AST; 277 | let error = undefined; 278 | try { 279 | newAst = jsonata(newText || text).ast() as AST; 280 | // if (validator) { 281 | // await validator(newAst); 282 | // } 283 | } catch (e) { 284 | error = "Parsing Error: " + e.message; 285 | setParsing({ 286 | inProgress: false, 287 | error: error 288 | }); 289 | setError && setError(error); 290 | return; 291 | } 292 | setParsing({ 293 | inProgress: false, 294 | error: error 295 | }); 296 | setError && setError(undefined); 297 | onChangeAst(newAst); 298 | } 299 | 300 | function textChange(newText: string) { 301 | if (typeof newText !== "string") throw Error("Invalid text"); 302 | onChangeMemo(newText); 303 | doParsing(newText); 304 | } 305 | 306 | return ( 307 | 308 | ); 309 | } 310 | 311 | function RootNodeEditor(props: NodeEditorProps): JSX.Element { 312 | const { theme } = Context.useContainer(); 313 | const editor = ; 314 | return ; 315 | } 316 | 317 | function BinaryEditor(props: NodeEditorProps) { 318 | if (Object.keys(Consts.combinerOperators).includes(props.ast.value)) { 319 | return ; 320 | } 321 | if (Object.keys(Consts.comparionsOperators).includes(props.ast.value)) { 322 | return ; 323 | } 324 | if (Object.keys(Consts.mathOperators).includes(props.ast.value)) { 325 | return ; 326 | } 327 | } 328 | 329 | function ComparisonEditor(props: NodeEditorProps): JSX.Element { 330 | const { theme } = Context.useContainer(); 331 | 332 | const swap = props.ast.value === "in"; 333 | const leftKey = !swap ? "lhs" : "rhs"; 334 | const rightKey = !swap ? "rhs" : "lhs"; 335 | 336 | const changeOperator = (value: BinaryNode["value"]) => { 337 | const newValue: BinaryNode = { ...props.ast, value: value }; 338 | const swap = (ast: BinaryNode) => { 339 | return { ...ast, lhs: ast.rhs, rhs: ast.lhs }; 340 | }; 341 | if (props.ast.value === "in" && newValue.value !== "in") { 342 | // do swap 343 | props.onChange(swap(newValue)); 344 | } else if (newValue.value === "in" && props.ast.value !== "in") { 345 | // do swap 346 | props.onChange(swap(newValue)); 347 | } else { 348 | props.onChange(newValue); 349 | } 350 | }; 351 | const lhsProps = { 352 | ast:props.ast[leftKey], 353 | onChange: (newAst: AST) => 354 | props.onChange({ ...props.ast, [leftKey]: newAst }) 355 | } 356 | const rhsProps = { 357 | ast:props.ast[rightKey], 358 | onChange: (newAst: AST) => 359 | props.onChange({ ...props.ast, [rightKey]: newAst }) 360 | } 361 | const lhs = ( 362 | 365 | ); 366 | const rhs = ( 367 | 370 | ); 371 | return ( 372 | 381 | ); 382 | } 383 | 384 | function flattenBinaryNodesThatMatch({ 385 | ast, 386 | onChange, 387 | parentType 388 | }: { 389 | ast: AST; 390 | onChange: OnChange; 391 | parentType: string; 392 | }): NodeEditorProps[] { 393 | if (ast.type === "binary" && ast.value === parentType) { 394 | // Flatten 395 | return [ 396 | ...flattenBinaryNodesThatMatch({ 397 | ast: ast.lhs, 398 | onChange: newAst => onChange({ ...ast, lhs: newAst }), 399 | parentType 400 | }), 401 | ...flattenBinaryNodesThatMatch({ 402 | ast: ast.rhs, 403 | onChange: newAst => onChange({ ...ast, rhs: newAst }), 404 | parentType 405 | }) 406 | ]; 407 | } else { 408 | // Don't flatten 409 | return [{ ast, onChange }]; 410 | } 411 | } 412 | 413 | function buildFlattenedBinaryValueSwap({ 414 | ast, 415 | parentType, 416 | newValue 417 | }: { 418 | ast: AST; 419 | parentType: String; 420 | newValue: BinaryNode["value"]; 421 | }): AST { 422 | if (ast.type === "binary" && ast.value === parentType) { 423 | return { 424 | ...ast, 425 | lhs: buildFlattenedBinaryValueSwap({ 426 | ast: ast.lhs, 427 | parentType, 428 | newValue 429 | }), 430 | rhs: buildFlattenedBinaryValueSwap({ 431 | ast: ast.rhs, 432 | parentType, 433 | newValue 434 | }), 435 | value: newValue 436 | }; 437 | } else { 438 | return ast; 439 | } 440 | } 441 | 442 | type CombinerProps = NodeEditorProps; 443 | 444 | function CombinerEditor(props: CombinerProps): JSX.Element { 445 | const { theme, defaultProvider } = Context.useContainer(); 446 | const flattenedBinaryNodes = flattenBinaryNodesThatMatch({ 447 | ast: props.ast, 448 | onChange: props.onChange, 449 | parentType: props.ast.value 450 | }); 451 | const removeLast = () => props.onChange(props.ast.lhs); 452 | const addNew = () => 453 | onChange({ 454 | type: "binary", 455 | value: props.ast.value, 456 | lhs: props.ast, 457 | rhs: defaultProvider.defaultComparison(), 458 | position: 0 459 | } as BinaryNode); 460 | 461 | const onChange = (val: AST) => 462 | props.onChange( 463 | buildFlattenedBinaryValueSwap({ 464 | ast: props.ast, 465 | // @ts-ignore 466 | newValue: val, 467 | parentType: props.ast.value 468 | }) 469 | ); 470 | const childNodes = flattenedBinaryNodes.map(c => ({ 471 | editor: , 472 | ast: c.ast, 473 | onChange: c.onChange 474 | })); 475 | 476 | const children = childNodes.map(c => c.editor); 477 | 478 | return ( 479 | 488 | ); 489 | } 490 | 491 | function PathEditor({ 492 | ast, 493 | onChange, 494 | validator, 495 | cols = "5" 496 | }: NodeEditorProps): JSX.Element { 497 | const { theme, schemaProvider, defaultProvider } = Context.useContainer(); 498 | const changeType = () => onChange(nextAst(ast, defaultProvider)); 499 | 500 | return ( 501 | 508 | ); 509 | } 510 | 511 | function nextAst(ast: AST, defaults: DefaultProvider): AST { 512 | // If a math expression has been typed as a string, we can upconvert it 513 | if (ast.type === "string") { 514 | try { 515 | const testAst = jsonata(ast.value as string).ast() as AST; 516 | if (isMath(testAst)) { 517 | return testAst; 518 | } 519 | } catch (e) { 520 | 521 | } 522 | } 523 | 524 | if (ast.type !== "path") { 525 | // @ts-ignore 526 | if (ast.value && !isNaN(ast.value)) { 527 | try { 528 | return jsonata(ast.value as string).ast() as AST; 529 | } catch (e) { 530 | return defaults.defaultPath(); 531 | } 532 | } else { 533 | // Numbers aren't valid paths, so we can't just switch to them 534 | return defaults.defaultPath(); 535 | } 536 | } else if (ast.type === "path") { 537 | return { type: "string", value: serializer(ast), position: 0 } as AST; 538 | } 539 | throw new Error("Unhandled AST type"); 540 | } 541 | 542 | function isNumber(str: string): boolean { 543 | if (typeof str !== "string") return false; // we only process strings! 544 | // could also coerce to string: str = ""+str 545 | // @ts-ignore -- expect error 546 | return !isNaN(str) && !isNaN(parseFloat(str)); 547 | } 548 | 549 | function autoCoerce(newValue: string): AST { 550 | const cleanVal = newValue.trim().toLowerCase(); 551 | if (isNumber(newValue)) { 552 | return { 553 | type: "number", 554 | value: parseFloat(newValue), 555 | position: 0 556 | }; 557 | } else if (["true", "false", "null"].includes(cleanVal)) { 558 | let value: any; 559 | if (cleanVal === "true") { 560 | value = true; 561 | } else if (cleanVal === "false") { 562 | value = false; 563 | } else if (cleanVal === "null") { 564 | value = null; 565 | } else { 566 | console.error("Invalid value node" + newValue); 567 | throw new Error("Unhandle value node" + newValue); 568 | } 569 | return { 570 | type: "value", 571 | value: value, 572 | position: 0 573 | }; 574 | } 575 | 576 | return { 577 | type: "string", 578 | value: newValue, 579 | position: 0 580 | }; 581 | } 582 | 583 | function toEditableText(ast: AST): string { 584 | if (ast.type === "string") return ast.value; 585 | if (ast.type === "number") return ast.value.toString(); 586 | if (ast.type === "value") { 587 | if (ast.value === null) return "null"; 588 | if (ast.value === false) return "false"; 589 | if (ast.value === true) return "true"; 590 | } 591 | } 592 | 593 | function CoercibleValueEditor({ 594 | ast, 595 | onChange, 596 | validator, 597 | cols = "5" 598 | }: NodeEditorProps): JSX.Element { 599 | const { theme, defaultProvider } = Context.useContainer(); 600 | const changeType = () => onChange(nextAst(ast, defaultProvider)); 601 | // let error = validator && validator(ast); 602 | const text = toEditableText(ast); 603 | const onChangeText = (newText: string) => onChange(autoCoerce(newText)); 604 | return ( 605 | 613 | ); 614 | } 615 | 616 | function BlockEditor({ 617 | ast, 618 | onChange 619 | }: NodeEditorProps): JSX.Element { 620 | const { theme } = Context.useContainer(); 621 | 622 | const childNodes = ast.expressions.map((exp: AST, idx: number) => { 623 | const changeExpr = newAst => { 624 | const newExpressions: AST[] = [...ast.expressions]; 625 | newExpressions[idx] = newAst; 626 | const newBlock: BlockNode = { 627 | ...ast, 628 | // @ts-ignore -- There's something weird going on with the array typing here. Likely caused by jsonata-ui-core? 629 | expressions: newExpressions 630 | }; 631 | onChange(newBlock); 632 | }; 633 | return { 634 | editor: , 635 | ast: exp, 636 | onChange: changeExpr 637 | }; 638 | }); 639 | const children = childNodes.map(c=>c.editor); 640 | 641 | return ( 642 | 648 | ); 649 | } 650 | 651 | type FlattenerProps = { 652 | ast: AST; 653 | onChange: OnChange; 654 | }; 655 | 656 | type Flattened = { 657 | pairs: { 658 | condition: FlattenerProps; 659 | then: FlattenerProps; 660 | original: { 661 | ast: ConditionNode; 662 | onChange: OnChange; 663 | }; 664 | }[]; 665 | finalElse?: FlattenerProps; 666 | }; 667 | 668 | function flattenConditions({ ast, onChange }: FlattenerProps): Flattened { 669 | if (ast.type === "condition") { 670 | const handlers = { 671 | condition: (newAst: AST) => 672 | onChange({ 673 | ...ast, 674 | condition: newAst 675 | }), 676 | then: (newAst: AST) => 677 | onChange({ 678 | ...ast, 679 | then: newAst 680 | }), 681 | else: (newAst: AST) => 682 | onChange({ 683 | ...ast, 684 | else: newAst 685 | }) 686 | }; 687 | 688 | 689 | if(!ast.else){ 690 | return { 691 | pairs: [ 692 | { 693 | condition: { 694 | ast: ast.condition, 695 | onChange: handlers.condition 696 | }, 697 | then: { 698 | ast: ast.then, 699 | onChange: handlers.then 700 | }, 701 | original: { 702 | ast, 703 | onChange 704 | } 705 | } 706 | ], 707 | } 708 | } 709 | const nested = flattenConditions({ 710 | ast: ast.else, 711 | onChange: handlers.else 712 | }); 713 | 714 | return { 715 | pairs: [ 716 | { 717 | condition: { 718 | ast: ast.condition, 719 | onChange: handlers.condition 720 | }, 721 | then: { 722 | ast: ast.then, 723 | onChange: handlers.then 724 | }, 725 | original: { 726 | ast, 727 | onChange 728 | } 729 | }, 730 | ...nested.pairs 731 | ], 732 | finalElse: nested.finalElse 733 | }; 734 | } 735 | 736 | return { 737 | pairs: [], 738 | finalElse: { 739 | ast, 740 | onChange 741 | } 742 | }; 743 | } 744 | 745 | function VariableEditor({ 746 | ast, 747 | onChange, 748 | cols = "5" 749 | }: NodeEditorProps): JSX.Element { 750 | const { theme, boundVariables = [] } = Context.useContainer(); 751 | return ( 752 | 758 | ); 759 | } 760 | 761 | function ConditionEditor({ 762 | ast, 763 | onChange 764 | }: NodeEditorProps): JSX.Element { 765 | const { theme, defaultProvider } = Context.useContainer(); 766 | 767 | const flattened = flattenConditions({ ast, onChange }); 768 | const { pairs } = flattened; 769 | const removeLast = () => { 770 | // Make the second-to-last condition's else = final else 771 | if (pairs.length <= 1) return; // Can't flatten a single-level condition 772 | const secondLast = pairs[pairs.length - 2].original; 773 | if(flattened.finalElse){ 774 | secondLast.onChange({ 775 | ...secondLast.ast, 776 | else: flattened.finalElse.ast 777 | }); 778 | } else { 779 | secondLast.onChange({ 780 | ...secondLast.ast 781 | }); 782 | } 783 | }; 784 | const addNew = () => { 785 | const last = pairs[pairs.length - 1].original; 786 | if(flattened.finalElse){ 787 | last.onChange({ 788 | ...last.ast, 789 | else: { 790 | ...defaultProvider.defaultCondition(), 791 | else: flattened.finalElse.ast 792 | } 793 | }); 794 | } else { 795 | last.onChange({ 796 | ...last.ast, 797 | else: { 798 | ...defaultProvider.defaultCondition() 799 | } 800 | }); 801 | } 802 | }; 803 | 804 | const removeAst = (ast: ConditionNode, onChange: OnChange) => 805 | ast.else ? 806 | onChange(ast.else) 807 | : onChange(null) 808 | 809 | const children = flattened.pairs.map(pair => { 810 | const Then = ; 811 | const Condition = ; 812 | const remove = () => removeAst(pair.original.ast, pair.original.onChange); 813 | return { 814 | Then, 815 | Condition, 816 | remove, 817 | ast: pair.original.ast, 818 | onChange: pair.original.onChange 819 | }; 820 | }); 821 | 822 | if(!flattened.finalElse){ 823 | return ( 824 | ); 831 | } else { 832 | const elseEditor = ; 833 | return ( 834 | ); 842 | } 843 | } 844 | 845 | function BindEditor({ ast, onChange }: NodeEditorProps): JSX.Element { 846 | const { theme } = Context.useContainer(); 847 | 848 | const lhsProps = { 849 | ast: ast.lhs, 850 | onChange: (newAst: VariableNode) => onChange({ ...ast, lhs: newAst } as BindNode) 851 | } 852 | const rhsProps = { 853 | ast: ast.rhs, 854 | onChange: (newAst: AST) => onChange({ ...ast, rhs: newAst } as BindNode) 855 | } 856 | const lhs = ( 857 | 860 | ); 861 | const rhs = ( 862 | 865 | ); 866 | 867 | return ; 868 | } 869 | 870 | function ObjectUnaryEditor({ 871 | ast, 872 | onChange 873 | }: NodeEditorProps): JSX.Element { 874 | const { theme, defaultProvider } = Context.useContainer(); 875 | 876 | const removeLast = () => { 877 | onChange({ 878 | ...ast, 879 | lhs: ast.lhs.slice(0, -1) 880 | } as ObjectUnaryNode); 881 | }; 882 | const addNew = () => { 883 | const newPair = [ 884 | defaultProvider.defaultString(), 885 | defaultProvider.defaultComparison() 886 | ]; 887 | onChange({ 888 | ...ast, 889 | lhs: [...ast.lhs, newPair] 890 | } as ObjectUnaryNode); 891 | }; 892 | const removeIndex = (idx: number) => 893 | onChange({ 894 | ...ast, 895 | lhs: ast.lhs.filter((_, i) => i !== idx) 896 | } as ObjectUnaryNode); 897 | 898 | const children = ast.lhs.map((pair: [AST, AST], idx: number) => { 899 | const changePair = (newAst: AST, side: 0 | 1) => { 900 | const newLhs: AST[][] = [...ast.lhs]; 901 | const newPair = [...pair]; 902 | newPair[side] = newAst; 903 | newLhs[idx] = newPair; 904 | onChange({ 905 | ...ast, 906 | lhs: newLhs 907 | } as AST); 908 | }; 909 | const changeKey = newAst => changePair(newAst, 0); 910 | const changeValue = newAst => changePair(newAst, 1); 911 | const keyProps = { 912 | ast: pair[0], 913 | onChange: changeKey 914 | }; 915 | const valueProps = { 916 | ast: pair[1], 917 | onChange: changeValue 918 | }; 919 | const key = ; 920 | const value = ; 921 | const remove = () => removeIndex(idx); 922 | return { key, value, remove, keyProps, valueProps }; // as const 923 | }); 924 | 925 | return ( 926 | 933 | ); 934 | } 935 | 936 | // Copies an array with an element missing, see: https://jaketrent.com/post/remove-array-element-without-mutating/ 937 | function withoutIndex(arr: T[], idx: number) { 938 | return [...arr.slice(0, idx), ...arr.slice(idx + 1)]; 939 | } 940 | 941 | function ArrayUnaryEditor({ 942 | ast, 943 | onChange 944 | }: NodeEditorProps): JSX.Element { 945 | const { theme, defaultProvider } = Context.useContainer(); 946 | 947 | const removeLast = () => { 948 | onChange({ 949 | ...ast, 950 | expressions: ast.expressions.slice(0, -1) 951 | }); 952 | }; 953 | const addNew = () => { 954 | onChange({ 955 | ...ast, 956 | expressions: [...ast.expressions, defaultProvider.defaultComparison()] 957 | }); 958 | }; 959 | const children = ast.expressions.map((expr: AST, idx: number) => { 960 | const changePair = (newAst: AST) => { 961 | const newExpr: AST[] = [...ast.expressions]; 962 | newExpr[idx] = newAst; 963 | onChange({ 964 | ...ast, 965 | expressions: newExpr 966 | }); 967 | }; 968 | const editor = ( 969 | changePair(newAst)} 972 | cols="12" 973 | /> 974 | ); 975 | const remove = () => { 976 | const newExpr: AST[] = withoutIndex(ast.expressions, idx); 977 | onChange({ 978 | ...ast, 979 | expressions: newExpr 980 | }); 981 | }; 982 | return { editor, remove, ast: expr, onChange: changePair }; 983 | }); 984 | 985 | return ( 986 | 993 | ); 994 | } 995 | 996 | function ApplyEditor(props: NodeEditorProps): JSX.Element { 997 | const { theme } = Context.useContainer(); 998 | const { baseLeft, chain } = flattenApply(props.ast, props.onChange); 999 | const lhs = ; 1000 | const childNodes = chain.map(c => ({ 1001 | editor: , 1002 | ast: c.ast, 1003 | onChange: c.onChange 1004 | })); 1005 | const children = childNodes.map(c => c.editor); 1006 | return ( 1007 | 1015 | ); 1016 | } 1017 | 1018 | type FlattenResult = { 1019 | baseLeft: FlattenerProps; 1020 | chain: FlattenerProps[]; 1021 | }; 1022 | function flattenApply(ast: ApplyNode, onChange: OnChange): FlattenResult { 1023 | if (ast.type === "apply") { 1024 | const right = { 1025 | ast: ast.rhs, 1026 | onChange: newAst => { 1027 | onChange({ 1028 | ...ast, 1029 | rhs: newAst 1030 | }); 1031 | } 1032 | }; 1033 | if (ast.lhs.type === "apply") { 1034 | const child = flattenApply(ast.lhs, newAst => { 1035 | onChange({ 1036 | ...ast, 1037 | lhs: newAst 1038 | }); 1039 | }); 1040 | return { 1041 | baseLeft: child.baseLeft, 1042 | chain: [...child.chain, right] 1043 | }; 1044 | } else { 1045 | return { 1046 | baseLeft: { 1047 | ast: ast.lhs, 1048 | onChange: newAst => { 1049 | onChange({ 1050 | ...ast, 1051 | lhs: newAst 1052 | }); 1053 | } 1054 | }, 1055 | chain: [right] 1056 | }; 1057 | } 1058 | } else { 1059 | return { 1060 | baseLeft: { 1061 | ast, 1062 | onChange 1063 | }, 1064 | chain: [] 1065 | }; 1066 | } 1067 | } 1068 | 1069 | function FunctionEditor({ 1070 | ast, 1071 | onChange 1072 | }: NodeEditorProps): JSX.Element { 1073 | const { theme } = Context.useContainer(); 1074 | const argumentNodes = ast.arguments.map((a, idx) => { 1075 | const changeArg = (newAst: AST) => { 1076 | const newArgs: AST[] = [...ast.arguments]; 1077 | newArgs[idx] = newAst; 1078 | onChange({ 1079 | ...ast, 1080 | // @ts-ignore -- something funky going on here with array types. 1081 | arguments: newArgs 1082 | } as FunctionNode); 1083 | }; 1084 | return { 1085 | editor: , 1086 | ast: a, 1087 | onChange: changeArg 1088 | }; 1089 | }); 1090 | const args = argumentNodes.map(c => c.editor); 1091 | const changeProcedure = (value: string) => 1092 | onChange({ 1093 | ...ast, 1094 | procedure: { 1095 | ...ast.procedure, 1096 | value 1097 | } 1098 | }); 1099 | return ( 1100 | 1107 | ); 1108 | } 1109 | 1110 | function MathEditor(props: NodeEditorProps): JSX.Element { 1111 | const { theme, defaultProvider } = Context.useContainer(); 1112 | const [text, setText] = useState(serializer(props.ast)); 1113 | const [parsing, setParsing] = useState({ 1114 | inProgress: false, 1115 | }); 1116 | const changeType = () => { props.onChange(nextAst(props.ast, defaultProvider)) }; 1117 | const parts = flattenMathParts(props.ast, props.onChange); 1118 | 1119 | function onChangeText(newText: string) { 1120 | let error: string | undefined = undefined; 1121 | setParsing({ 1122 | inProgress: true, 1123 | error 1124 | }); 1125 | try { 1126 | const newAst = jsonata(newText).ast() as AST; 1127 | if (!isMath(newAst)) { 1128 | throw new Error("that's not a math expressions"); 1129 | } 1130 | props.onChange(newAst); 1131 | } catch (e) { 1132 | error = "Parsing Error: " + e.message; 1133 | } finally { 1134 | setParsing({ 1135 | inProgress: false, 1136 | error 1137 | }); 1138 | setText(newText); 1139 | } 1140 | } 1141 | 1142 | return ( 1143 | 1151 | ); 1152 | } 1153 | 1154 | function flattenMathParts(ast: AST, onChange: OnChange, collectedParts: MathPart[] = []): MathPart[] { 1155 | function onChangeOperator(newOperator: string) { 1156 | if (Object.keys(Consts.mathOperators).includes(newOperator)) { 1157 | onChange({ ...ast, value: newOperator } as BinaryNode); 1158 | } else { 1159 | throw new Error("Not a valid math operator"); 1160 | } 1161 | } 1162 | 1163 | if (isMath(ast)) { 1164 | flattenMathParts( 1165 | ast.lhs, 1166 | (newAst: AST) => onChange({ ...ast, lhs: newAst }), 1167 | collectedParts 1168 | ); 1169 | collectedParts.push({ 1170 | type: "operator", 1171 | operator: ast.value, 1172 | onChangeOperator 1173 | }); 1174 | flattenMathParts( 1175 | ast.rhs, 1176 | (newAst: AST) => onChange({ ...ast, rhs: newAst }), 1177 | collectedParts 1178 | ); 1179 | } else { 1180 | collectedParts.push({ 1181 | type: "ast", 1182 | ast, 1183 | onChange, 1184 | editor: 1185 | }); 1186 | } 1187 | return collectedParts; 1188 | } 1189 | 1190 | -------------------------------------------------------------------------------- /src/Consts.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Binary operators that apply (mostly) to numbers. 3 | * 4 | * These do work with string, boolean, etc. but they don't make sense for most end-users 5 | */ 6 | export const numberOperators = { 7 | ">": "greater than", 8 | "<": "less than", 9 | "<=": "less than or equal", 10 | ">=": "greater than or equal" 11 | } as const; 12 | 13 | /** 14 | * Base operators that apply to all types (string, number, boolean, null) 15 | */ 16 | export const baseOperators = { 17 | "=": "equals", 18 | "!=": "not equals" 19 | } as const; 20 | 21 | /** 22 | * Only applies to array functions 23 | */ 24 | export const arrayOperators = { 25 | in: "array contains" 26 | } as const; 27 | 28 | /** 29 | * Set of all comparion operators. These operators should all return boolean values. 30 | */ 31 | export const comparionsOperators = { 32 | ...baseOperators, 33 | ...numberOperators, 34 | ...arrayOperators 35 | } as const; 36 | 37 | /** 38 | * Combiner operators. These operators should return boolean values 39 | */ 40 | export const combinerOperators = { 41 | and: "and", 42 | or: "or" 43 | } as const; 44 | 45 | /** 46 | * Math operators. These operators should return number values 47 | */ 48 | export const mathOperators = { 49 | "-": "minus", 50 | "+": "plus", 51 | "*": "times", 52 | "/": "divided by", 53 | "%": "modulo" 54 | } as const; 55 | 56 | export const stringOperators = { 57 | "&": "concatenate" 58 | } as const; 59 | 60 | /** 61 | * Set of *all* binary operators 62 | */ 63 | export const operators = { 64 | ...comparionsOperators, 65 | ...mathOperators, 66 | ...combinerOperators, 67 | ...stringOperators 68 | } as const; 69 | -------------------------------------------------------------------------------- /src/Theme.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | BinaryNode, 4 | PathNode, 5 | LiteralNode, 6 | BlockNode, 7 | ConditionNode, 8 | VariableNode, 9 | ObjectUnaryNode, 10 | ArrayUnaryNode, 11 | FunctionNode, 12 | ApplyNode, 13 | BindNode 14 | } from "jsonata-ui-core"; 15 | import { 16 | ParsingState, 17 | Modes, 18 | Mode, 19 | AST, 20 | NodeEditorProps, 21 | SchemaProvider 22 | } from "./Types"; 23 | 24 | type Callback = () => void; 25 | type OnChange = (val: T) => void; 26 | type Children = JSX.Element[]; 27 | 28 | type Comp = React.ComponentType; 29 | 30 | export type Theme = { 31 | Base: Comp; 32 | RootNodeEditor: Comp; 33 | IDETextarea: Comp; 34 | 35 | /* 36 | Compound editors 37 | */ 38 | ComparisonEditor: Comp; 39 | CombinerEditor: Comp; 40 | BlockEditor: Comp; 41 | ConditionEditor: Comp; 42 | ObjectUnaryEditor: Comp; 43 | ArrayUnaryEditor: Comp; 44 | ApplyEditor: Comp; 45 | FunctionEditor: Comp; 46 | 47 | /* 48 | Leaf editors 49 | */ 50 | BindEditor: Comp; 51 | VariableEditor: Comp; 52 | LeafValueEditor: Comp; 53 | PathEditor: Comp; 54 | 55 | /* 56 | Math editors 57 | */ 58 | MathEditor: Comp; 59 | }; 60 | 61 | export interface IDETextareaProps { 62 | textChange: OnChange; 63 | text: string; 64 | parsing: ParsingState; 65 | } 66 | 67 | export type ChildNodeProps = { 68 | editor: JSX.Element; 69 | ast: NodeEditorProps["ast"]; 70 | onChange: NodeEditorProps["onChange"]; 71 | }; 72 | 73 | export type CombinerEditorProps = NodeEditorProps & { 74 | addNew: Callback; 75 | removeLast: Callback; 76 | combinerOperators: { [key: string]: string }; 77 | // @deprecated. use ChildNodes 78 | children: JSX.Element[]; 79 | childNodes: ChildNodeProps[]; 80 | }; 81 | 82 | export type BlockEditorProps = NodeEditorProps & { 83 | children: Children; 84 | childNodes: ChildNodeProps[]; 85 | }; 86 | 87 | export type ObjectUnaryEditorProps = NodeEditorProps & { 88 | addNew: Callback; 89 | removeLast: Callback; 90 | children: { 91 | key: JSX.Element; 92 | value: JSX.Element; 93 | remove: Callback; 94 | keyProps: NodeEditorProps; 95 | valueProps: NodeEditorProps; 96 | }[]; 97 | }; 98 | 99 | export type VariableEditorProps = NodeEditorProps & { 100 | boundVariables: string[]; 101 | }; 102 | 103 | export type ArrayUnaryEditorProps = NodeEditorProps & { 104 | children: (ChildNodeProps & { remove: Callback })[]; 105 | addNew: Callback; 106 | removeLast: Callback; 107 | }; 108 | 109 | export type LeafValueEditorProps = NodeEditorProps & { 110 | text: string; 111 | onChangeText: OnChange; 112 | changeType: Callback; 113 | }; 114 | 115 | export type PathEditorProps = NodeEditorProps & { 116 | changeType: Callback; 117 | schemaProvider?: SchemaProvider; 118 | }; 119 | 120 | export type BaseEditorProps = { 121 | toggleMode: Callback; 122 | toggleBlock: string | null; 123 | mode: Mode; 124 | editor: JSX.Element; 125 | }; 126 | 127 | export type RootNodeEditorProps = NodeEditorProps & { 128 | editor: JSX.Element; 129 | }; 130 | 131 | export type ConditionEditorProps = NodeEditorProps & { 132 | addNew: Callback; 133 | removeLast: Callback; 134 | elseEditor?: JSX.Element; 135 | children: { 136 | Then: JSX.Element; 137 | Condition: JSX.Element; 138 | remove: Callback; 139 | ast: NodeEditorProps["ast"]; 140 | onChange: NodeEditorProps["onChange"]; 141 | }[]; 142 | }; 143 | 144 | export type ComparisonEditorProps = NodeEditorProps & { 145 | lhs: JSX.Element; 146 | rhs: JSX.Element; 147 | lhsProps: NodeEditorProps; 148 | rhsProps: NodeEditorProps; 149 | changeOperator: OnChange; 150 | }; 151 | 152 | export type ApplyEditorProps = NodeEditorProps & { 153 | lhs: JSX.Element; 154 | children: Children; 155 | lhsProps: NodeEditorProps; 156 | childNodes: ChildNodeProps[]; 157 | }; 158 | 159 | export type FunctionEditorProps = NodeEditorProps & { 160 | args: Children; 161 | argumentNodes: ChildNodeProps[]; 162 | changeProcedure: OnChange; 163 | }; 164 | 165 | export type BindEditorProps = NodeEditorProps & { 166 | lhs: JSX.Element; 167 | rhs: JSX.Element; 168 | lhsProps: NodeEditorProps; 169 | rhsProps: NodeEditorProps; 170 | }; 171 | 172 | export type MathEditorProps = NodeEditorProps & { 173 | text: string; 174 | children: MathPart[]; 175 | changeType: Callback; 176 | } & IDETextareaProps; 177 | 178 | export type MathPart = 179 | | ({ type: "ast", children?: MathPart[] } & ChildNodeProps) 180 | | { 181 | type: "operator"; 182 | operator: string; 183 | onChangeOperator: OnChange; 184 | }; 185 | -------------------------------------------------------------------------------- /src/Types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JsonataASTNode, 3 | StringNode, 4 | NumberNode, 5 | BinaryNode, 6 | ConditionNode, 7 | PathNode 8 | } from "jsonata-ui-core"; 9 | import { Theme } from "./Theme"; 10 | import { Path } from "./schema/PathSuggester"; 11 | 12 | /** 13 | * Tracks parsing state for IDE edtiors. Allows for asynchronous parsing. 14 | */ 15 | export interface ParsingState { 16 | // If progress is in progress. 17 | inProgress: boolean; 18 | // The current parsing error, or undefined 19 | error?: string; 20 | } 21 | 22 | /** 23 | * The editor can either be in one of two modes: 24 | * 25 | * - NodeMode - A visual editor. Good for a subset of JSONata expressions. 26 | * - IDEMode - A text editor. Allows for arbitrary JSONata expessions. 27 | */ 28 | export type Mode = "NodeMode" | "IDEMode"; 29 | const NodeMode = "NodeMode"; 30 | const IDEMode = "IDEMode"; 31 | export const Modes = { NodeMode, IDEMode }; 32 | 33 | /** 34 | * Represents the internal JSONata expression. 35 | * 36 | * This uses a replacement for the `ExprNode` type from the core `jsonata` module, 37 | * because `ExprNode` is incomplete an causes errors. 38 | */ 39 | export type AST = JsonataASTNode; 40 | 41 | /** 42 | * Convenience type for onChange props. 43 | */ 44 | export type OnChange = (ast: T) => void; 45 | 46 | /** 47 | * All editors accept this interface as commons props. Used in theming. 48 | * 49 | * For props that need to be nested, use React Context or unstated-next to pass items deep into your editor tree. 50 | */ 51 | export interface NodeEditorProps { 52 | ast: NodeType; 53 | onChange: OnChange; 54 | /** 55 | * Number of columns. The default theme uses a 12-column design 56 | */ 57 | cols?: string; 58 | /** 59 | * An optional editor. Allows parents to create restrictions on their direct descendants 60 | * 61 | * TODO: Need to finish providing support across all editor types 62 | */ 63 | validator?: (ast: NodeType) => ValidatorError; 64 | } 65 | 66 | /** 67 | * An unstated-next container that allows access to global context variables 68 | */ 69 | export type Container = { 70 | schemaProvider?: SchemaProvider; 71 | theme: Theme; 72 | boundVariables?: string[]; 73 | defaultProvider: DefaultProvider; 74 | }; 75 | 76 | /** 77 | * Props for the base editor. 78 | */ 79 | export interface EditorProps { 80 | 81 | /** 82 | * The current expression value for the editor 83 | */ 84 | text: string; 85 | /** 86 | * A callback for when the value changes 87 | */ 88 | onChange: (text: string) => void; 89 | /** 90 | * A theme to use for styling the editor. See the default theme, based on Bootstrap4. 91 | */ 92 | theme: Theme; 93 | /** 94 | * A JSON Schema for path suggestions and validation. 95 | * 96 | * `schemaProvider` will override the default provider from `schema` 97 | */ 98 | schema?: any; 99 | /** 100 | * An optional shema provider. Used for path completion 101 | * 102 | * Will override the default schema provider via (schema) 103 | */ 104 | schemaProvider?: SchemaProvider; 105 | /** 106 | * The set of valid variables used in a Variable Editor. 107 | */ 108 | boundVariables?: string[]; 109 | /** 110 | * Provides default values for nodes when a new node is created 111 | */ 112 | defaultProvider?: Partial; 113 | /** 114 | * Controls when a String can be swapped to a visual editor. 115 | * 116 | * On null can be switched. 117 | * On error, shows that error. 118 | */ 119 | isValidBasicExpression?: (ast: AST) => string | null; 120 | } 121 | 122 | export interface DefaultProvider { 123 | defaultString(): StringNode; 124 | defaultNumber(): NumberNode; 125 | defaultComparison(): BinaryNode; 126 | defaultCondition(): ConditionNode; 127 | defaultPath(): PathNode; 128 | } 129 | /** 130 | * Used to provide JSON-schema style information about paths. 131 | * 132 | * Useful for type hints, validations and errors. 133 | * 134 | * e.g. if it's not a number, don't allow `math` operators (`-`, `+`, etc.) 135 | * e.g. id it's not a string, don't allow `string` operators (`&`, etc.) 136 | */ 137 | export interface SchemaProvider { 138 | /** 139 | * Best effort look up the type at a path. If the type can't be inferred, returns null 140 | */ 141 | getTypeAtPath(ast: AST): string | null; 142 | /** 143 | * Best effor to look up valid paths 144 | */ 145 | getPaths(ast: AST): Path[]; 146 | } 147 | 148 | /** 149 | * For showing errors in editors. Used in theming 150 | */ 151 | export interface ValidatorError { 152 | // Machine-friendly error code 153 | error: string; 154 | // Human-friendly error message 155 | message: string; 156 | } 157 | -------------------------------------------------------------------------------- /src/example/Flowchart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import { DefaultTheme } from "../theme/DefaultTheme"; 4 | import { 5 | FlowChart, 6 | INodeInnerDefaultProps, 7 | INode, 8 | ILink, 9 | IFlowChartCallbacks, 10 | IChart 11 | } from "@mrblenny/react-flow-chart"; 12 | import styled from "styled-components"; 13 | import { ConditionEditorProps } from "../Theme"; 14 | 15 | const Outer = styled.div` 16 | padding: 30px; 17 | `; 18 | 19 | type ConditionProps = typeof DefaultTheme.ConditionEditor; 20 | 21 | type NodesAndLinks = { 22 | nodes: { 23 | [id: string]: INode; 24 | }; 25 | links: { 26 | [id: string]: ILink; 27 | }; 28 | }; 29 | 30 | const NullCallback = () => undefined; 31 | const Callbacks: IFlowChartCallbacks = { 32 | onDragNode: NullCallback, 33 | onDragCanvas: NullCallback, 34 | onCanvasDrop: NullCallback, 35 | onLinkStart: NullCallback, 36 | onLinkMove: NullCallback, 37 | onLinkComplete: NullCallback, 38 | onLinkCancel: NullCallback, 39 | onPortPositionChange: NullCallback, 40 | onLinkMouseEnter: NullCallback, 41 | onLinkMouseLeave: NullCallback, 42 | onLinkClick: NullCallback, 43 | onCanvasClick: NullCallback, 44 | onDeleteKey: NullCallback, 45 | onNodeClick: NullCallback, 46 | onNodeSizeChange: NullCallback 47 | }; 48 | 49 | function makeGraph(child: Child, idx: number): NodesAndLinks { 50 | const id = "tier" + idx; 51 | const thenId = id + "then"; 52 | const linkId = id + "_link"; 53 | const parentLinkId = id + "_parent_link"; 54 | const parentLink = 55 | idx > 0 56 | ? {} 57 | : { 58 | [parentLinkId]: { 59 | id: parentLinkId, 60 | from: { 61 | nodeId: "tier" + (idx - 1), 62 | portId: "else" 63 | }, 64 | to: { 65 | nodeId: id, 66 | portId: "parent" 67 | } 68 | } 69 | }; 70 | return { 71 | nodes: { 72 | [id]: { 73 | id: id, 74 | type: "condition", 75 | properties: { 76 | jsx: child.Condition 77 | }, 78 | position: { 79 | x: 10, 80 | y: 200 * idx 81 | }, 82 | ports: { 83 | parent: { 84 | id: "parent", 85 | type: "input" 86 | }, 87 | then: { 88 | id: "then", 89 | type: "output" 90 | }, 91 | else: { 92 | id: "else", 93 | type: "output" 94 | } 95 | } 96 | }, 97 | [thenId]: { 98 | id: thenId, 99 | type: "then", 100 | properties: { 101 | jsx: child.Then 102 | }, 103 | position: { 104 | x: 650, 105 | y: 200 * idx + 50 106 | }, 107 | ports: { 108 | trigger: { 109 | id: "trigger", 110 | type: "input" 111 | } 112 | } 113 | } 114 | }, 115 | links: { 116 | [linkId]: { 117 | id: linkId, 118 | from: { 119 | nodeId: id, 120 | portId: "then" 121 | }, 122 | to: { 123 | nodeId: thenId, 124 | portId: "trigger" 125 | } 126 | } 127 | } 128 | }; 129 | } 130 | type Child = { 131 | Then: JSX.Element; 132 | Condition: JSX.Element; 133 | remove: () => void; 134 | }; 135 | 136 | export default function App(props: ConditionEditorProps) { 137 | const { children } = props; 138 | const graph = children.reduce( 139 | (obj: NodesAndLinks, child: Child, idx: number) => { 140 | const graph = makeGraph(child, idx); 141 | return { 142 | ...obj, 143 | nodes: { 144 | ...obj.nodes, 145 | ...graph.nodes 146 | }, 147 | links: { 148 | ...obj.links, 149 | ...graph.links 150 | } 151 | }; 152 | }, 153 | {} as NodesAndLinks 154 | ); 155 | const chartSimple:IChart = { 156 | offset: { 157 | x: 0, 158 | y: 0 159 | }, 160 | nodes: graph.nodes, 161 | links: graph.links, 162 | selected: {}, 163 | hovered: {} 164 | }; 165 | const chart2 = { 166 | offset: { x: 0, y: 0 }, 167 | nodes: { 168 | tier0: { 169 | id: "tier0", 170 | type: "condition", 171 | properties: {}, 172 | position: { x: 300, y: 100 }, 173 | ports: { 174 | then: { id: "then", type: "output" }, 175 | else: { id: "else", type: "output" } 176 | } 177 | }, 178 | tier0then: { 179 | id: "tier0then", 180 | type: "then", 181 | properties: {}, 182 | position: { x: 300, y: 100 }, 183 | ports: { trigger: { id: "trigger", type: "input" } } 184 | }, 185 | tier1: { 186 | id: "tier1", 187 | type: "condition", 188 | properties: {}, 189 | position: { x: 300, y: 100 }, 190 | ports: { 191 | then: { id: "then", type: "output" }, 192 | else: { id: "else", type: "output" } 193 | } 194 | }, 195 | tier1then: { 196 | id: "tier1then", 197 | type: "then", 198 | properties: {}, 199 | position: { x: 300, y: 100 }, 200 | ports: { trigger: { id: "trigger", type: "input" } } 201 | } 202 | }, 203 | links: { 204 | tier0: { 205 | id: "tier0", 206 | type: "condition", 207 | properties: {}, 208 | position: { x: 300, y: 100 }, 209 | ports: { 210 | then: { id: "then", type: "output" }, 211 | else: { id: "else", type: "output" } 212 | } 213 | }, 214 | tier0then: { 215 | id: "tier0then", 216 | type: "then", 217 | properties: {}, 218 | position: { x: 300, y: 100 }, 219 | ports: { trigger: { id: "trigger", type: "input" } } 220 | }, 221 | tier1: { 222 | id: "tier1", 223 | type: "condition", 224 | properties: {}, 225 | position: { x: 300, y: 100 }, 226 | ports: { 227 | then: { id: "then", type: "output" }, 228 | else: { id: "else", type: "output" } 229 | } 230 | }, 231 | tier1then: { 232 | id: "tier1then", 233 | type: "then", 234 | properties: {}, 235 | position: { x: 300, y: 100 }, 236 | ports: { trigger: { id: "trigger", type: "input" } } 237 | } 238 | }, 239 | selected: {}, 240 | hovered: {} 241 | }; 242 | 243 | return ( 244 | 251 | ); 252 | } 253 | 254 | function NodeInnerCustom({ node, config }: INodeInnerDefaultProps) { 255 | return ( 256 | 257 |

{node.type}

258 | {node.properties.jsx} 259 |
260 | ); 261 | } 262 | -------------------------------------------------------------------------------- /src/example/PurchaseEvent.schema.js: -------------------------------------------------------------------------------- 1 | export default { 2 | $schema: "http://json-schema.org/draft-04/schema#", 3 | title: "Payment Event Fields Schema", 4 | description: 5 | "Defines the event fields our system recognizes as a purchase event", 6 | type: "object", 7 | properties: { 8 | checkout_id: { 9 | type: ["string", null], 10 | title: "Checkout ID", 11 | description: "The checkout ID associated with this purchase", 12 | maxLength: 375 13 | }, 14 | order_id: { 15 | type: "string", 16 | title: "Order ID", 17 | description: "The order or transaction ID associated with this purchase", 18 | maxLength: 375 19 | }, 20 | affiliation: { 21 | type: "string", 22 | title: "Affiliation", 23 | description: 24 | "Store or affiliation from which this transaction occurred (e.g. Google Store)", 25 | maxLength: 375 26 | }, 27 | total: { 28 | type: "number", 29 | title: "Purchase Total", 30 | minimum: 0, 31 | description: "Revenue with discounts and coupons added in" 32 | }, 33 | revenue: { 34 | type: "number", 35 | title: "Revenue", 36 | minimum: 0, 37 | description: 38 | "Revenue associated with the transaction (excluding shipping and tax). This is the field we use to calculate a customer's LTV." 39 | }, 40 | shipping: { 41 | type: "number", 42 | title: "Shipping Cost", 43 | minimum: 0, 44 | description: "Shipping cost associated with the transaction" 45 | }, 46 | tax: { 47 | type: "number", 48 | title: "Total Tax", 49 | minimum: 0, 50 | description: "Total tax associated with the transaction" 51 | }, 52 | discount: { 53 | type: "number", 54 | title: "Discount", 55 | minimum: 0, 56 | description: "Total discount associated with the transaction" 57 | }, 58 | coupon: { 59 | type: "string", 60 | title: "Coupon", 61 | description: "Transaction coupon redeemed with the transaction", 62 | maxLength: 375 63 | }, 64 | currency: { 65 | type: "string", 66 | title: "Currency", 67 | description: "The ISO currency code used in this purchase", 68 | pattern: "^[A-Z]{3}$" 69 | }, 70 | products: { 71 | type: "array", 72 | title: "Products", 73 | maxItems: 200, 74 | items: { 75 | type: "object", 76 | properties: { 77 | product_id: { 78 | type: "string", 79 | title: "Product ID", 80 | description: "Database id of the product being viewed", 81 | maxLength: 375 82 | }, 83 | sku: { 84 | type: "string", 85 | title: "Stock Keeping Unit", 86 | description: "Sku of the product being viewed", 87 | maxLength: 375 88 | }, 89 | category: { 90 | type: "string", 91 | title: "Product Category", 92 | description: "Product category being viewed", 93 | maxLength: 375 94 | }, 95 | name: { 96 | type: "string", 97 | title: "Product Name", 98 | description: "Name of the product being viewed", 99 | maxLength: 375 100 | }, 101 | brand: { 102 | type: "string", 103 | title: "Brand", 104 | description: "Brand associated with the product", 105 | maxLength: 375 106 | }, 107 | variant: { 108 | type: "string", 109 | title: "Product Variant", 110 | description: "Variant of the product (e.g. Black)", 111 | maxLength: 375 112 | }, 113 | price: { 114 | type: "number", 115 | title: "Price", 116 | description: "Price of the product being viewed", 117 | minimum: 0 118 | }, 119 | quantity: { 120 | type: "integer", 121 | title: "Quantity", 122 | description: "Quantity of a product", 123 | minimum: 0 124 | }, 125 | coupon: { 126 | type: "string", 127 | title: "Coupon", 128 | description: 129 | "Coupon code associated with a product (e.g MAY_DEALS_3)", 130 | maxLength: 375 131 | }, 132 | position: { 133 | type: "integer", 134 | title: "Product Position", 135 | description: "Position in the product list (ex. 3)" 136 | }, 137 | url: { 138 | type: "string", 139 | title: "Product URL", 140 | description: "URL of the product page", 141 | maxLength: 375 142 | }, 143 | image_url: { 144 | type: "string", 145 | title: "Image URL", 146 | description: "Image url of the product", 147 | maxLength: 375 148 | } 149 | }, 150 | required: ["product_id"], 151 | additionalProperties: false 152 | } 153 | } 154 | }, 155 | additionalProperties: false 156 | }; 157 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Col, Badge, Button } from "react-bootstrap"; 4 | 5 | import jsonata from "jsonata"; 6 | import { serializer, ConditionNode } from "jsonata-ui-core"; 7 | 8 | import { Editor, defaultIsValidBasicExpression } from "./AstEditor"; 9 | import { DefaultTheme } from "./theme/DefaultTheme"; 10 | import { AST } from "./Types"; 11 | import { makeSchemaProvider } from "./schema/SchemaProvider"; 12 | 13 | import PurchaseEvent from "./example/PurchaseEvent.schema"; 14 | import Flowchart from "./example/Flowchart"; 15 | 16 | // @ts-ignore 17 | const schemaProvider = makeSchemaProvider(PurchaseEvent); 18 | 19 | // (event) => rewardKey 20 | const apply = `foo ~> $contains("bar")`; 21 | const set = `[Q = 0, Q = 1, Q = 3]`; 22 | const obj = `{"one":Q = 0, "two": Q = 1, "three": Q = 3}`; 23 | const cond = `Q = 0 ? "Tier 1" : Q =1 ? "Tier 2" : "Tier 3"`; 24 | const singleCond = `($Q := products[product_id="seat"].quantity; $Q = 0 ? $tier1 : $Q = 1 ? $tier2 : $defaultTier)`; 25 | const fizzbuzz = `Q % 3 = 0 ? "Fizz" : Q % 5 = 0 ? "Buzz" : Q`; 26 | const math = `Q + 3 * 2 / (16 - 4 - Q) + $min(Q + 1)`; 27 | 28 | const defaultText: string = apply; 29 | const introspection = jsonata(`**[type="name"].value`); 30 | 31 | const options = [apply, set, obj, cond, singleCond, fizzbuzz, math]; 32 | 33 | // TODO : Make this recursive, smarter 34 | const NodeWhitelist = jsonata(` 35 | true or 36 | type = "binary" 37 | or (type ="block" and type.expressions[type!="binary"].$length = 0) 38 | `); 39 | 40 | function isValidBasicExpression(newValue: AST): string | null { 41 | // Check the default basic expression first if you just want to add to it 42 | const defaultResult = defaultIsValidBasicExpression(newValue); 43 | 44 | if (defaultResult === null) { 45 | try { 46 | if (NodeWhitelist.evaluate(newValue)) { 47 | return null; 48 | } 49 | } catch (e) {} 50 | return "Can't use basic editor for advanced expressions. Try a simpler expression."; 51 | } 52 | return defaultResult; 53 | } 54 | 55 | type VariableEditor = typeof DefaultTheme.VariableEditor; 56 | 57 | const CustomVariableEditor: VariableEditor = props => { 58 | if (props.ast.value === "Q") { 59 | return ( 60 | 61 | Tier Variable 62 | 63 | ); 64 | } 65 | if (props.ast.value.startsWith("tier")) { 66 | return ( 67 | 68 | {props.ast.value} 69 | 70 | ); 71 | } 72 | return ; 73 | }; 74 | 75 | function NewTierDefault(): ConditionNode { 76 | return { 77 | type: "condition", 78 | condition: { 79 | type: "binary", 80 | value: "<=", 81 | position: undefined, 82 | lhs: { 83 | value: "Q", 84 | type: "variable", 85 | position: undefined 86 | }, 87 | rhs: { 88 | value: 1, 89 | type: "number", 90 | position: undefined 91 | } 92 | }, 93 | then: { 94 | value: "tier4", 95 | type: "variable", 96 | position: undefined 97 | }, 98 | else: { 99 | value: "defaultTier", 100 | type: "variable", 101 | position: undefined 102 | }, 103 | position: undefined, 104 | value: undefined 105 | }; 106 | } 107 | 108 | function App() { 109 | const [text, setText] = useState(defaultText); 110 | 111 | let serializedVersions = []; 112 | let keys = []; 113 | let ast; 114 | try { 115 | ast = jsonata(text).ast() as AST; 116 | keys = introspection.evaluate(ast); 117 | try { 118 | serializedVersions.push(serializer(ast as AST)); 119 | } catch (e) { 120 | serializedVersions.push(e.message); 121 | } 122 | try { 123 | const l2 = serializer(jsonata(serializedVersions[0]).ast() as AST); 124 | serializedVersions.push(l2); 125 | } catch (e) { 126 | serializedVersions.push(e.message); 127 | } 128 | } catch (e) {} 129 | 130 | const boundVariables = [ 131 | "Q", 132 | "var", 133 | "var1", 134 | "var2", 135 | "tenantSettings", 136 | "tier1Name" 137 | ]; 138 | 139 | return ( 140 |
141 |

Query Builder

142 |

Filter for which Purchase events will trigger this program

143 | {/*
144 |