├── .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 |
*/}
152 |
167 | {serializedVersions.map((s, idx) => (
168 |
169 | {s}
170 |
171 | ))}
172 | {serializedVersions[0] === serializedVersions[1]
173 | ? "✓ serialized"
174 | : "✗ serializer bug"}
175 |
176 |
177 | Examples |
178 | {options.map(o => (
179 | |
180 | ))}
181 |
182 |
183 |
184 | Keys used: {JSON.stringify(keys, null, 2)} {typeof keys}
185 | {JSON.stringify(ast, null, 2)}
186 |
187 |
188 |
189 | );
190 | }
191 |
192 | const rootElement = document.getElementById("root");
193 | ReactDOM.render(, rootElement);
194 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/schema/PathSuggester.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchema4, JSONSchema4TypeName } from "json-schema";
2 | import { escapeString } from "jsonata-ui-core";
3 |
4 | type JsonSchema = JSONSchema4;
5 | type TypeName = JSONSchema4TypeName;
6 | const primitiveTypes = ["string", "number", "integer", "boolean"];
7 | type PrimitiveType = "string" | "number" | "integer" | "boolean"; // typeof primitiveTypes;
8 |
9 | export type Path = {
10 | path: string;
11 | title?: string;
12 | titlePath: string[];
13 | typePath: string[];
14 | description?: string;
15 | subPaths?: Path[];
16 | type: PrimitiveType;
17 | isJsonataSequence: boolean;
18 | };
19 |
20 | type ParentOpts = {
21 | pathPrefix?: string;
22 | isJsonataSequence?: boolean;
23 | titlePath?: string[];
24 | typePath?: TypeName[];
25 | };
26 |
27 | function getPaths(schema: JsonSchema, parentOpts: ParentOpts = {}): Path[] {
28 | const {
29 | pathPrefix,
30 | isJsonataSequence = false,
31 | titlePath = [],
32 | typePath = [],
33 | } = parentOpts;
34 | const pfixed = (k?: string) => {
35 | if (!k) return pathPrefix;
36 | return pathPrefix ? pathPrefix + "." + escapeString(k) : escapeString(k);
37 | };
38 |
39 | function ChildPathReducer(acc: Path[], s: JsonSchema): Path[] {
40 | return [...acc, ...getPaths(s, parentOpts)];
41 | }
42 | let paths: Path[] = [];
43 |
44 | if (!schema) return paths;
45 |
46 | if (schema.anyOf) {
47 | paths = [...paths, ...schema.anyOf.reduce(ChildPathReducer, [])];
48 | }
49 | if (schema.oneOf) {
50 | paths = [...paths, ...schema.oneOf.reduce(ChildPathReducer, [])];
51 | }
52 | if (schema.allOf) {
53 | paths = [...paths, ...schema.allOf.reduce(ChildPathReducer, [])];
54 | }
55 | const { type } = schema;
56 | if (type === "object") {
57 | const objectPaths = Object.keys(schema.properties || {}).reduce(
58 | (acc, k) => {
59 | const subSchema = schema.properties[k];
60 | const subPaths: Path[] = getPaths(subSchema, {
61 | ...parentOpts,
62 | pathPrefix: pfixed(k),
63 | titlePath: [...titlePath, schema.title],
64 | typePath: [...typePath, type],
65 | });
66 |
67 | return [...acc, ...subPaths];
68 | },
69 | []
70 | );
71 | paths = [...paths, ...objectPaths];
72 | } else if (type === "array") {
73 | paths = [
74 | ...getPaths(schema.items, {
75 | pathPrefix: pfixed(),
76 | titlePath: [...titlePath, schema.title],
77 | typePath: [...typePath, type],
78 | isJsonataSequence: true,
79 | }),
80 | // {
81 | // path: firstElementPath,
82 | // title: pfixedTitle("First Element"),
83 | // subPaths: getPaths(schema.items, {
84 | // pathPrefix: firstElementPath,
85 | // labelSuffix: pfixedTitle("of the First Element")
86 | // })
87 | // },
88 | // {
89 | // path: pfixed("$[-1]"),
90 | // title: pfixedTitle("LastElement Element")
91 | // // subPaths: getPaths(schema.items)
92 | // }
93 | ];
94 | } else if (!Array.isArray(type) && primitiveTypes.includes(type)) {
95 | const path: Path = {
96 | path: pfixed(),
97 | title: schema.title,
98 | description: schema.description,
99 | type: type as PrimitiveType,
100 | titlePath: [...titlePath, schema.title],
101 | typePath: [...typePath, type],
102 | isJsonataSequence,
103 | };
104 | paths = [...paths, path];
105 | } else if (Array.isArray(type)) {
106 | paths = [
107 | ...paths,
108 | ...(type as JSONSchema4TypeName[]).reduce((acc, singleType) => {
109 | if (singleType === "null") return acc;
110 | return [
111 | ...acc,
112 | {
113 | path: pfixed(),
114 | title: schema.title,
115 | description: schema.description,
116 | type: singleType as PrimitiveType,
117 | titlePath: [...titlePath, schema.title],
118 | typePath: [...typePath, singleType],
119 | isJsonataSequence,
120 | },
121 | ];
122 | }, []),
123 | ];
124 | }
125 | return paths;
126 | }
127 |
128 | export default getPaths;
129 |
--------------------------------------------------------------------------------
/src/schema/SchemaProvider.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchema4 } from "json-schema";
2 | import { SchemaProvider, AST } from "../Types";
3 | import getPaths from "./PathSuggester";
4 |
5 | export function makeSchemaProvider(schema: JSONSchema4): SchemaProvider {
6 | return {
7 | getPaths(ast: AST) {
8 | return getPaths(schema);
9 | },
10 | getTypeAtPath(path: AST) {
11 | return null;
12 | }
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/specs/BinaryFlattening.feature:
--------------------------------------------------------------------------------
1 | Feature: Binary Flattening
2 |
3 | In JSONata the expression `a and b and c` is turned into an AST that looks like `(a and b) and c`.
4 |
5 | This works great for the backend logic, but isn't great for building a visual editor.
6 |
7 | To support a nicer boolean logic building experience, our visual builder needs to flatten
8 | nested binary `and` and `or` conditions when the binary logic permits it.
9 |
10 |
11 | Scenario: Simple binary expressions are flattened
12 | Given an expression
13 | """
14 | a and b and c
15 | """
16 | When the basic editor renders
17 | Then it shows three conditions grouped together
18 | | a |
19 | | b |
20 | | c |
21 |
22 | Scenario: Switching and/or changes all the nodes
23 | Given an expression
24 | """
25 | a and b and c
26 | """
27 | When the basic editor renders
28 | And I change the type from "and" to "or"
29 | Then the output expression should backend
30 | """
31 | a or b or c
32 | """
33 |
--------------------------------------------------------------------------------
/src/specs/EditorToggling.feature:
--------------------------------------------------------------------------------
1 | Feature: Toggling between Basic and advanced
2 |
3 | JSONata is a big complex language, and it's not easy to allow all posibilities with a visual editor.
4 | Advanced users can use custom expressions, and a visual builder lets basic users get started quickly.
5 |
6 | Scenario: Can switch to Basic editor on binary expressions
7 | Given the expression is simple
8 | """
9 | revenue > 30
10 | """
11 | Then [Switch to Basic] is enabled
12 |
13 |
14 | Scenario: Can't switch on errors
15 | Given the expression is invalid JSONata
16 | """
17 | error nd 111 -9=-9
18 | """
19 | Then [Switch to Basic] is disabled
20 |
21 |
22 | Scenario: Can't switch on complex expressions
23 | Given the expression is advanced
24 | """
25 | "12bbasc" in Product.SKU ? (Product.Price > 30) : (Product.Price > 100)
26 | """
27 | Then [Switch to Basic] is disabled
28 |
29 |
--------------------------------------------------------------------------------
/src/specs/PathSuggestions.feature:
--------------------------------------------------------------------------------
1 | Feature: Suggesting Paths from a JSON Schema source
2 |
3 | When a customer is sending us data, or has configured data, then we can
4 | suggest paths automatically based on the JSON Schema definitions they have.
5 |
6 | This makes writing examples easier.
--------------------------------------------------------------------------------
/src/specs/TypeCoercion.feature:
--------------------------------------------------------------------------------
1 | Feature: Type Coercion
2 |
3 | Usually when people type numbers, they are intended to be used as numbers, not as strings.
4 | Similarly when people write "true" or "false", they intend for those to be used as boolean values, not as strings.
5 | The type coersion in the value editor uses these assumptions to make it faster
6 | and easier for people to just type what they want, and it's processed accordingly
7 |
8 | Scenario Outline: Automaticly switching expression based on what you type
9 | Given I'm typing into the String editor
10 | When I type
11 | """
12 |
13 | """
14 | Then the expression should be
15 | """
16 |