├── .gitignore ├── src ├── Test.md ├── index.tsx ├── utils │ ├── DataStructure.ts │ ├── MockData.ts │ └── Types.ts ├── Test.tsx ├── components │ ├── Expression.tsx │ ├── Dropdown.tsx │ └── DownshiftSelect.tsx └── __tests__ │ ├── Extractor.test.tsx │ └── __snapshots__ │ └── Extractor.test.tsx.snap ├── .npmignore ├── .babelrc ├── .github └── workflows │ └── test.yml ├── styleguide.config.js ├── jest.config.js ├── webpack.config.js ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | styleguide 5 | -------------------------------------------------------------------------------- /src/Test.md: -------------------------------------------------------------------------------- 1 | Expression builder 2 | 3 | ```jsx 4 | import Editor from './Test' 5 | ; 6 | ``` 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | demo 3 | coverage 4 | .github 5 | tsconfig.json 6 | jest.config.js 7 | .babelrc 8 | webpack.config.js 9 | styleguide.config.js -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"], 4 | "overrides": [ 5 | { 6 | "test": ["./src/**/*.tsx", "./src/*.tsx", "./src/*.ts", "./src/**/*.ts"], 7 | "presets": ["@babel/preset-typescript"] 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install dependencies 15 | run: yarn 16 | - name: Run tests 17 | run: yarn test 18 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | components: 'src/*.tsx', 3 | propsParser: require('react-docgen-typescript').withCustomConfig( 4 | './tsconfig.json' 5 | ).parse, 6 | webpackConfig: { 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.(tsx|ts)?$/, 11 | exclude: /node_modules/, 12 | loader: 'babel-loader' 13 | } 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest' 4 | }, 5 | 6 | preset: 'ts-jest', 7 | globals: { 8 | 'ts-jest': { 9 | diagnostics: false 10 | } 11 | }, 12 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 13 | modulePathIgnorePatterns: ['/dist'], 14 | testPathIgnorePatterns: ['/dist/', '/node_modules/'] 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var PeerDepsExternalsPlugin = require('peer-deps-externals-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'production', 6 | entry: './src/index.tsx', 7 | plugins: [new PeerDepsExternalsPlugin()], 8 | output: { 9 | path: path.resolve('dist'), 10 | filename: 'main.js', 11 | libraryTarget: 'commonjs2' 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.tsx', '.js'] 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(tsx|ts)?$/, 20 | exclude: /(node_modules)/, 21 | use: 'babel-loader' 22 | } 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | // Target latest version of ECMAScript. 5 | "target": "esnext", 6 | // Search under node_modules for non-relative imports. 7 | "moduleResolution": "node", 8 | // Process & infer types from .js files. 9 | "allowJs": true, 10 | // Don't emit; allow Babel to transform files. 11 | "noEmit": true, 12 | // Enable strictest settings like strictNullChecks & noImplicitAny. 13 | "strict": true, 14 | // Disallow features that require cross-file information for emit. 15 | "isolatedModules": true, 16 | // Import non-ES modules as default imports. 17 | "esModuleInterop": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import Dropdown from './components/Dropdown' 3 | import { EditorState, TreeNode } from './utils/DataStructure' 4 | import { EditorType, ExtractorProps } from './utils/Types' 5 | 6 | export { EditorState, TreeNode } from './utils/DataStructure' 7 | 8 | const ExpressionBuilder: FC = props => { 9 | // create a root node for the component 10 | const rootNode = new TreeNode(null) 11 | // initialize the editor state with root node 12 | const EditorData: EditorType = new EditorState(rootNode) 13 | 14 | return 15 | } 16 | 17 | export default ExpressionBuilder 18 | -------------------------------------------------------------------------------- /src/utils/DataStructure.ts: -------------------------------------------------------------------------------- 1 | import { NodeType, TreeNodeValueType } from './Types' 2 | 3 | export class TreeNode { 4 | value: TreeNodeValueType 5 | children: NodeType[] 6 | constructor(value: TreeNodeValueType) { 7 | this.value = value 8 | this.children = [] 9 | } 10 | 11 | addChild(node: NodeType) { 12 | this.children.push(node) 13 | } 14 | 15 | setValue(val: TreeNodeValueType) { 16 | this.value = val 17 | } 18 | 19 | clearChildren() { 20 | this.children = [] 21 | } 22 | } 23 | 24 | export class EditorState { 25 | root: NodeType 26 | 27 | constructor(node: NodeType) { 28 | this.root = node 29 | } 30 | 31 | buildExpression = (node: NodeType = this.root): any => { 32 | let str = '' 33 | if (node.value.type !== 'fn') return node.value.data 34 | node.children.forEach((child, idx) => { 35 | str += this.buildExpression(child) 36 | str += idx === node.children.length - 1 ? '' : ', ' 37 | }) 38 | return `${node.value.data.label} (${str})` 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ExpressionBuilder from './index' 3 | import { functions, staticValues } from './utils/MockData' 4 | 5 | const options = [...functions, ...staticValues] 6 | 7 | const stringRegex = /"([^\\"]|\\")*"/ 8 | 9 | const cb1 = () => alert('valid!') 10 | const cb2 = () => alert('invalid!') 11 | 12 | const onChangeFn = st => console.log('change', st.buildExpression()) 13 | const expressionRootClass = 'root-class' 14 | const expressionInputClass = 'input-class' 15 | 16 | const validationFn = val => { 17 | // mock api request 18 | const res = !isNaN(val) || stringRegex.test(val) 19 | // console.log(res) 20 | return res 21 | } 22 | 23 | const Root = () => { 24 | return ( 25 | <> 26 | 35 | 36 | ) 37 | } 38 | 39 | export default Root 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anshuman Verma 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 | -------------------------------------------------------------------------------- /src/utils/MockData.ts: -------------------------------------------------------------------------------- 1 | export const functions = [ 2 | { 3 | value: 'SPLIT (dim, delimiter, occurrence_number)', 4 | label: 'SPLIT', 5 | type: 'function', 6 | key: 'split', 7 | keyLabel: 'f(x)', 8 | params: ['dim', 'delimiter', 'occurrence_number'], 9 | helper: 10 | 'Returns the nth substring divided by a specified delimiter. Index, n, starts from 0' 11 | }, 12 | { 13 | value: 'CONCAT (dim1, dim2)', 14 | label: 'CONCAT', 15 | key: 'concat', 16 | params: ['dim1', 'dim2'], 17 | type: 'function', 18 | keyLabel: 'f(x)', 19 | helper: 'Returns the concatenation of two strings.' 20 | }, 21 | { 22 | value: 'SUB (dim, starting_at, ending_at)', 23 | label: 'SUB', 24 | key: 'sub', 25 | params: ['dim', 'starting_at', 'ending_at'], 26 | type: 'function', 27 | keyLabel: 'f(x)', 28 | helper: 29 | 'Returns a substring between specified character indices. Index starts from 0' 30 | }, 31 | { 32 | value: 'EXTRACT (dim, prefix_string, suffix_string)', 33 | label: 'EXTRACT', 34 | key: 'extract', 35 | params: ['dim1', 'prefix_string', 'suffix_string'], 36 | type: 'function', 37 | keyLabel: 'f(x)', 38 | helper: 39 | 'Returns a substring between the first prefix_string and first suffix_string' 40 | } 41 | ] 42 | 43 | export const staticValues = [ 44 | { 45 | value: 'ACCOUNT', 46 | label: 'Account', 47 | type: 'dimension', 48 | keyLabel: 'dim', 49 | key: 'account', 50 | helper: 'Account dimension' 51 | }, 52 | { 53 | value: 'AD', 54 | label: 'Ad', 55 | type: 'dimension', 56 | keyLabel: 'dim', 57 | key: 'ad', 58 | helper: 'Account ad' 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-expression-builder", 3 | "version": "1.0.8", 4 | "description": "A bare-bones react component to build function expressions.", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "test": "jest", 8 | "start": "styleguidist server", 9 | "build-example": "styleguidist build", 10 | "build": "webpack" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/anshumanv/react-expression-builder.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "component", 19 | "expression", 20 | "statement", 21 | "build" 22 | ], 23 | "author": "anshumanv", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/anshumanv/react-expression-builder/issues" 27 | }, 28 | "homepage": "https://github.com/anshumanv/react-expression-builder#readme", 29 | "peerDependencies": { 30 | "react": "^16.12.0", 31 | "react-dom": "^16.12.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.8.3", 35 | "@babel/plugin-proposal-class-properties": "^7.8.3", 36 | "@babel/preset-env": "^7.8.3", 37 | "@babel/preset-react": "^7.8.3", 38 | "@babel/preset-typescript": "^7.8.3", 39 | "@testing-library/jest-dom": "^5.9.0", 40 | "@testing-library/react": "^9.4.0", 41 | "@types/react": "^16.9.19", 42 | "babel-loader": "^8.0.6", 43 | "jest": "^26.0.1", 44 | "peer-deps-externals-webpack-plugin": "^1.0.4", 45 | "react": "^16.12.0", 46 | "react-docgen-typescript": "^1.16.2", 47 | "react-dom": "^16.12.0", 48 | "react-styleguidist": "^10.6.0", 49 | "ts-jest": "^26.1.0", 50 | "typescript": "^3.7.5", 51 | "webpack": "^4.5.0", 52 | "webpack-cli": "^3.2.1" 53 | }, 54 | "dependencies": { 55 | "downshift": "^4.0.7" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/Types.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, TreeNode } from './DataStructure' 2 | 3 | export interface ExtractorProps { 4 | onChangeFn: (editorState: EditorType) => void 5 | options: OptionType[] 6 | } 7 | 8 | export interface EditorType extends EditorState {} 9 | 10 | export interface NodeType extends TreeNode {} 11 | 12 | export interface OptionType { 13 | key: string 14 | type: string 15 | params?: string[] 16 | keyLabel?: string 17 | label: string 18 | } 19 | 20 | export interface TreeNodeValueType { 21 | data: OptionType 22 | type: string 23 | } 24 | 25 | export interface DropdownProps { 26 | inputRef?: React.RefObject 27 | inputPlaceholder?: string 28 | placeholder: string 29 | onKeyDown?: () => void 30 | initialFocus?: boolean 31 | node: NodeType 32 | EditorData: EditorType 33 | handleValueChange?: () => void 34 | options: OptionType[] 35 | validationFn: (val: any) => boolean 36 | inputValue?: any 37 | onChangeFn: (state: EditorType) => void 38 | expressionRootClass: string | undefined 39 | expressionInputClass: string | undefined 40 | } 41 | 42 | export interface SelectorPropTypes { 43 | inputRef: React.RefObject 44 | inputPlaceholder: string 45 | onKeyDown: (e) => void 46 | handleValueChange: (e) => void 47 | options: OptionType[] 48 | validationFn: (value: any) => boolean 49 | inputValue: string 50 | expressionInputClass: string | undefined 51 | } 52 | 53 | export interface ExpressionRootPropTypes { 54 | // function name that matches the input 55 | fname: string 56 | // parent node on which we append child dropdown as per the fn param 57 | node: NodeType 58 | // Editor data which needs to be passed down to every level 59 | EditorData: EditorType 60 | setExp: (isExpression: boolean) => void 61 | options: OptionType[] 62 | setValue: (value: string) => void 63 | onChangeFn: (state: EditorType) => void 64 | expressionRootClass: string | undefined 65 | expressionInputClass: string | undefined 66 | validationFn: (val: string) => boolean 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Expression.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useRef, useState } from 'react' 2 | import { TreeNode } from '../utils/DataStructure' 3 | import { ExpressionRootPropTypes, OptionType } from '../utils/Types' 4 | import Drop from './Dropdown' 5 | 6 | const Expression: FC = ( 7 | props: ExpressionRootPropTypes 8 | ) => { 9 | const { 10 | fname, 11 | node, 12 | EditorData, 13 | setExp, 14 | options, 15 | setValue, 16 | onChangeFn, 17 | expressionRootClass = '', 18 | expressionInputClass = '', 19 | validationFn 20 | } = props 21 | const [rootFocus, setRootFocus] = useState(false) 22 | const expressionRoot = useRef(null) 23 | // find function metadata as per the given key. 24 | const fnData: OptionType | undefined = options.find( 25 | f => f.key === fname.toLowerCase() 26 | ) 27 | 28 | useEffect(() => { 29 | // create nodes for all children of the given function 30 | const { params } = fnData! 31 | params!.forEach(() => { 32 | const refNode = new TreeNode({ data: '', type: 'string' }) 33 | node.addChild(refNode) 34 | }) 35 | }, []) 36 | 37 | const findNextNode = () => { 38 | const initNode = (expressionRoot.current as any).firstElementChild 39 | if (initNode.dataset.type === 'expression-root') return initNode 40 | return initNode.firstElementChild 41 | } 42 | 43 | const findPrevNode = () => { 44 | let initNode = expressionRoot.current as any 45 | if (initNode.previousElementSibling) { 46 | initNode = initNode.previousElementSibling 47 | while ( 48 | initNode.lastElementChild && 49 | initNode.dataset.type === 'expression-root' 50 | ) { 51 | initNode = initNode.lastElementChild 52 | } 53 | initNode = initNode.firstElementChild 54 | } else { 55 | if (initNode.parentElement.dataset.type === 'expression-root') 56 | initNode = initNode.parentElement 57 | } 58 | return initNode 59 | } 60 | 61 | const handleKeyDown = (e: React.KeyboardEvent) => { 62 | e.stopPropagation() 63 | // remove node when backspace is pressed and expression is in focus 64 | switch (e.keyCode) { 65 | case 8: 66 | case 46: 67 | if (rootFocus) { 68 | setExp(false) 69 | node.clearChildren() 70 | setValue('') 71 | } 72 | break 73 | case 39: 74 | findNextNode().focus() 75 | break 76 | case 37: 77 | findPrevNode().focus() 78 | break 79 | default: 80 | return 81 | } 82 | } 83 | 84 | // Build the dom with dropdowns for the parameters of the function 85 | const PHDom = () => { 86 | const { params } = fnData! 87 | return params!.map((param, i) => { 88 | return ( 89 | 101 | ) 102 | }) 103 | } 104 | if (fname) { 105 | return ( 106 | setRootFocus(true)} 112 | onBlur={() => setRootFocus(false)} 113 | tabIndex={0} 114 | style={{ display: 'flex' }} 115 | > 116 | {fnData!.label}({PHDom()}) 117 | 118 | ) 119 | } 120 | return null 121 | } 122 | 123 | export default Expression 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

react-expression-builder

2 |

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 |
18 | 19 |

A bare-bones react component to build function expressions with your data.

20 | 21 | ### Features 22 | 23 | - Typeahead support 24 | - Full keyboard navigation and deletion 25 | - Easy custom styling as per input 26 | - Input validation at granular level 27 | - Customizable options 28 | - Single Dependency, no bloat 29 | 30 | ### Installation 31 | 32 | Install the package - 33 | 34 | ```sh 35 | npm i react-expression-builder 36 | 37 | OR 38 | 39 | yarn add react-expression-builder 40 | ``` 41 | 42 | ### Usage 43 | 44 | ```js 45 | import ExpressionBuilder from 'react-expression-builder' 46 | 47 | //1. accumulate your options 48 | // fn must have an additional property 'params' - eg `params: ['dim', 'delimiter', 'occurrence_number']` 49 | const options = [{..., key: '...', type: '...', label: '...',...}, {...}] 50 | 51 | // regex to match entires within "" 52 | const stringRegex = /"([^\\"]|\\")*"/ 53 | 54 | // Optional - Function called on every state change, store your changes on the server 55 | const onChangeFn = editorState => console.log(editorState, editorState.buildExpression()) 56 | 57 | // Optional - class for the expression element, use for optional styling 58 | const expressionRootClass = 'root-class' 59 | 60 | // Optional - class for the input container 61 | const expressionInputClass = 'input-class' 62 | 63 | // Optional - Function which validates all the input values and returns a bool. 64 | const validationFn = val => { 65 | return !isNaN(val) || stringRegex.test(val) 66 | } 67 | 68 | 77 | 78 | ``` 79 | 80 | ### Data Structure 81 | 82 | Uses an N-Ary tree to store/manipulate the expression data, simple recursive function gives you the complete string. You can check [DataStructure.ts](https://github.com/anshumanv/react-expression-builder/blob/master/src/utils/DataStructure.ts) for the simple implementation, if curious. 83 | 84 | Note - This only gives the skeleton and functionality, styling is upto the user, you can either make use of respective classes or wrap this component in a CSS-in-JS solution. For example, a nicely styled solution would look somewhat like [this](https://knitui.design/?path=/story/extractor--basic). This is not complete yet, need more work. Meanwhile, suggestions are appreciated. 85 | 86 | ## Author 87 | 88 | [Anshuman Verma](https://github.com/anshumanv) 89 | 90 | [](https://twitter.com/Anshumaniac12) 91 | [](https://linkedin.com/in/anshumanv12) 92 | [](https://www.facebook.com/anshumanv12) 93 | [](https://www.paypal.me/AnshumanVerma) 94 | 95 | ## Contribute 96 | 97 | Found a bug, please [create an issue](https://github.com/anshumanv/react-expression-builder/issues/new) 98 | 99 | ## License 100 | 101 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg)](https://github.com/anshumanv/react-expression-builder/blob/master/LICENSE) 102 | 103 | > © Anshuman Verma 104 | -------------------------------------------------------------------------------- /src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import { DropdownProps } from '../utils/Types' 3 | import DownSelect from './DownshiftSelect' 4 | import Exp from './Expression' 5 | 6 | const Drop = (props: DropdownProps) => { 7 | const { 8 | placeholder, 9 | EditorData, 10 | node, 11 | initialFocus = false, 12 | options, 13 | validationFn, 14 | onChangeFn, 15 | expressionRootClass, 16 | expressionInputClass 17 | } = props 18 | 19 | const [value, setValue] = useState('') 20 | const [exp, setExp] = useState(false) 21 | const dropRef = useRef(null) 22 | 23 | useEffect(() => { 24 | if (dropRef.current && initialFocus) { 25 | dropRef.current.focus() 26 | } 27 | }, [dropRef.current]) 28 | 29 | const fnKeys: string[] = options 30 | .filter(fn => fn.type === 'function') 31 | .map(fn => fn.key) 32 | 33 | const getValueType = value => { 34 | if (fnKeys.includes(value.toLowerCase())) return 'fn' 35 | const listOption = options.find(option => option.label === value) 36 | return listOption ? listOption.type : 'string' 37 | } 38 | 39 | const getValueData = (type, value) => { 40 | if (type === 'fn') { 41 | return options.find(f => f.key === value.toLowerCase()) 42 | } 43 | return value 44 | } 45 | 46 | const handleValueChange = e => { 47 | const val = e.target.value 48 | const valueType = getValueType(val) 49 | const valueData = getValueData(valueType, val) 50 | setValue(val) 51 | node.setValue({ data: valueData, type: valueType }) 52 | 53 | // In case the input is a function, scaffold it's params 54 | if (valueType === 'fn') setExp(true) 55 | 56 | if (onChangeFn) onChangeFn(EditorData) 57 | } 58 | 59 | const getNextNode = () => { 60 | let currElement 61 | if (dropRef.current) { 62 | currElement = dropRef.current.parentElement 63 | } 64 | // if the present element has a next sibling, directly switch to next 65 | if (currElement.nextElementSibling) 66 | currElement = currElement.nextElementSibling 67 | // this is when you want to skip the top levels and only switch in text fields 68 | else { 69 | while (currElement && !currElement.nextElementSibling) { 70 | currElement = currElement.parentElement 71 | if ( 72 | currElement && 73 | currElement.dataset.type === 'knit-ui_extractor-root' 74 | ) 75 | return 76 | } 77 | currElement = currElement.nextElementSibling 78 | } 79 | 80 | if (currElement.dataset.type !== 'expression-root') 81 | currElement = currElement.firstElementChild 82 | 83 | return currElement 84 | } 85 | 86 | const getPrevNode = () => { 87 | let currElement 88 | if (dropRef.current) { 89 | currElement = dropRef.current.parentElement 90 | } 91 | // if the present element has a next sibling, directly switch to next 92 | if (currElement.previousElementSibling) 93 | currElement = currElement.previousElementSibling 94 | else { 95 | if (!currElement.previousElementSibling) 96 | currElement = currElement.parentElement 97 | } 98 | // some transitions as per the element we arrive on 99 | while (currElement.dataset.type === 'expression-root' && !initialFocus) { 100 | currElement = currElement.lastElementChild 101 | } 102 | if (currElement.dataset.type === 'expression-input-root') { 103 | currElement = currElement.firstElementChild 104 | } 105 | return currElement 106 | } 107 | 108 | const handleDir = e => { 109 | e.stopPropagation() 110 | const inputNode = dropRef.current! 111 | switch (e.keyCode) { 112 | case 39: 113 | // only at last caret position 114 | if (inputNode.selectionStart !== inputNode.value.length) return 115 | e.preventDefault() 116 | const nextNode = getNextNode() 117 | if (nextNode) nextNode.focus() 118 | break 119 | case 37: 120 | // only when at caret position is 0 121 | if (inputNode.selectionStart !== 0) return 122 | e.preventDefault() 123 | const prevNode = getPrevNode() 124 | if (prevNode) prevNode.focus() 125 | break 126 | default: 127 | break 128 | } 129 | } 130 | 131 | if (!exp) { 132 | return ( 133 | handleDir(e)} 138 | inputValue={value} 139 | validationFn={validationFn} 140 | expressionInputClass={expressionInputClass} 141 | handleValueChange={handleValueChange} 142 | /> 143 | ) 144 | } 145 | return ( 146 | 158 | ) 159 | } 160 | 161 | export default Drop 162 | -------------------------------------------------------------------------------- /src/components/DownshiftSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useCombobox } from 'downshift' 2 | import React, { CSSProperties, useEffect, useRef, useState } from 'react' 3 | import { OptionType, SelectorPropTypes } from '../utils/Types' 4 | 5 | const menuStyles: CSSProperties = { 6 | backgroundColor: 'white', 7 | fontWeight: 'normal', 8 | position: 'absolute' 9 | } 10 | 11 | const textContentStyle: CSSProperties = { 12 | position: 'absolute', 13 | top: 0, 14 | left: 0, 15 | visibility: 'hidden', 16 | height: 0, 17 | overflow: 'scroll', 18 | whiteSpace: 'pre' 19 | } 20 | 21 | const DropdownCombobox: React.FC = ( 22 | props: SelectorPropTypes 23 | ) => { 24 | const { 25 | inputRef, 26 | inputPlaceholder, 27 | onKeyDown, 28 | handleValueChange, 29 | options, 30 | validationFn, 31 | inputValue, 32 | expressionInputClass 33 | } = props 34 | 35 | const [inputItems, setInputItems] = useState(options) 36 | const [valid, setValid] = useState(true) 37 | const [valueType, setValueType] = useState('string') 38 | 39 | const matchesAnInput: boolean = options.some( 40 | item => item.label === inputValue 41 | ) 42 | 43 | const textRef = useRef(null) 44 | 45 | useEffect(() => { 46 | setInputItems( 47 | options.filter(item => { 48 | return item.key.toLowerCase().startsWith(inputValue.toLowerCase()) 49 | }) 50 | ) 51 | if (validationFn) { 52 | validationFn(inputValue) || matchesAnInput 53 | ? setValid(true) 54 | : setValid(false) 55 | } 56 | }, [inputValue]) 57 | 58 | const handleValueTypeChange = (val: string): void => { 59 | // check if the input value is present in any of the option 60 | const isPresentInOptions = options.find(option => option.label === val) 61 | if (isPresentInOptions) { 62 | setValueType(isPresentInOptions.type) 63 | } else { 64 | setValueType('string') // can also use typeof formattedInputValue but it will result in string anyways 65 | } 66 | } 67 | 68 | const stateReducer = (state, actionAndChanges) => { 69 | const { stateChangeTypes } = useCombobox 70 | switch (actionAndChanges.type) { 71 | case stateChangeTypes.ItemClick: 72 | case stateChangeTypes.InputKeyDownEnter: 73 | // case useCombobox.stateChangeTypes.InputBlur: 74 | handleValueChange({ 75 | target: { 76 | value: 77 | state.highlightedIndex > -1 78 | ? actionAndChanges.changes.selectedItem.label 79 | : actionAndChanges.changes.inputValue || '' 80 | } 81 | }) 82 | if (state.highlightedIndex > -1) { 83 | // handleValueTypeChange(actionAndChanges.changes.selectedItem.label) 84 | setValueType(actionAndChanges.changes.selectedItem.type) 85 | } 86 | 87 | return { 88 | ...actionAndChanges.changes, 89 | // if we had an item highlighted in the previous state. 90 | ...(state.highlightedIndex > -1 && { 91 | inputValue: actionAndChanges.changes.selectedItem.key 92 | }) 93 | } 94 | default: 95 | return actionAndChanges.changes // otherwise business as usual. 96 | } 97 | } 98 | 99 | const { 100 | isOpen, 101 | openMenu, 102 | getMenuProps, 103 | getInputProps, 104 | getComboboxProps, 105 | highlightedIndex, 106 | getItemProps 107 | } = useCombobox({ 108 | items: inputItems, 109 | defaultIsOpen: true, 110 | initialIsOpen: true, 111 | initialHighlightedIndex: 0, 112 | stateReducer 113 | }) 114 | 115 | const updateValueAndType = (e: React.ChangeEvent): void => { 116 | const val = (e.target as HTMLInputElement).value 117 | 118 | handleValueChange(e) 119 | handleValueTypeChange(val) 120 | } 121 | 122 | const getInputWidth = () => { 123 | if (textRef.current) { 124 | return textRef.current.scrollWidth + 8 125 | } 126 | return '10rem' 127 | } 128 | 129 | const showMenu = isOpen && !matchesAnInput && inputItems.length > 0 130 | 131 | return ( 132 | <> 133 | {/* */} 134 |
139 | openMenu() 147 | })} 148 | style={{ 149 | width: getInputWidth() 150 | }} 151 | data-valid={valid} 152 | data-value-type={valueType} 153 | /> 154 |
155 | {inputValue || inputPlaceholder} 156 |
157 | {showMenu && ( 158 |
    163 | {inputItems.map((item, index) => ( 164 |
  • 176 | {item.label} 177 | {/*TODO: Will enable this sometime in future to show the type of expression */} 178 | {/* {item.keyLabel || ''} */} 179 |
  • 180 | ))} 181 |
182 | )} 183 |
184 | 185 | ) 186 | } 187 | 188 | export default DropdownCombobox 189 | -------------------------------------------------------------------------------- /src/__tests__/Extractor.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render } from '@testing-library/react' 2 | import React from 'react' 3 | import Extractor from '../' 4 | import { functions, staticValues } from '../utils/MockData' 5 | 6 | afterEach(cleanup) 7 | 8 | // functions for the extractor 9 | const options = [...functions, ...staticValues] 10 | 11 | const concatFunction = functions[1] 12 | const adDimension = staticValues[1] 13 | 14 | const onUpdate = editorState => { 15 | return editorState.buildExpression() 16 | } 17 | 18 | const extractorProps = { 19 | onChangeFn: onUpdate, 20 | options: options 21 | } 22 | 23 | describe('Extractor renders properly', () => { 24 | it('Renders correctly', () => { 25 | const { asFragment } = render() 26 | 27 | // Get the options list which should be shown by default 28 | const optionsList = document.querySelector('[data-type="expression-list"]') 29 | expect(optionsList).toBeInTheDocument() 30 | 31 | // Check initial focus on render 32 | const { activeElement } = document 33 | expect(activeElement!.parentElement).toHaveAttribute( 34 | 'data-type', 35 | 'expression-input-root' 36 | ) 37 | 38 | // Input field should how focus initially 39 | expect(asFragment()).toMatchSnapshot() 40 | }) 41 | it('Scaffolds an expression on typing one', () => { 42 | const { asFragment } = render() 43 | // Get the root input element 44 | const { activeElement } = document 45 | fireEvent.input(activeElement as any, { 46 | target: { value: functions[0].key } 47 | }) 48 | 49 | // Check that all params are scaffolded properly 50 | const expressionInputRoots = document.querySelectorAll( 51 | '[data-type="expression-input-root"]' 52 | ) 53 | expect(expressionInputRoots).toHaveLength(3) 54 | 55 | // Check that the expression root is created 56 | const expressionRoots = document.querySelectorAll( 57 | '[data-type="expression-root"]' 58 | ) 59 | expect(expressionRoots).toHaveLength(1) 60 | 61 | expect(asFragment()).toMatchSnapshot() 62 | }) 63 | it('Hides options list on blur', () => { 64 | const { asFragment } = render() 65 | // Initially the input has focus 66 | // Get the options list which should be shown by default 67 | const optionsList = document.querySelector('[data-type="expression-list"]') 68 | expect(optionsList).toBeInTheDocument() 69 | 70 | // create a new element and transfer focus 71 | const newInput = document.createElement('input') 72 | newInput.focus() 73 | 74 | expect(optionsList).not.toBeInTheDocument() 75 | 76 | expect(asFragment()).toMatchSnapshot() 77 | }) 78 | it('Applies correct data attributes', () => { 79 | const { asFragment } = render() 80 | 81 | // Get the root input element 82 | const { activeElement } = document 83 | fireEvent.input(activeElement as any, { 84 | target: { value: staticValues[1].label } 85 | }) 86 | 87 | // Check for attributes 88 | expect(activeElement).toHaveAttribute('data-value-type', 'dimension') 89 | 90 | expect(asFragment()).toMatchSnapshot() 91 | }) 92 | }) 93 | 94 | describe('Extractor builds data structure properly', () => { 95 | it('Fires the onChange function', () => { 96 | const onChangeFn = jest.fn() 97 | const { asFragment } = render( 98 | 99 | ) 100 | 101 | // Get the root input element 102 | const { activeElement } = document 103 | fireEvent.change(activeElement as any, { 104 | target: { value: adDimension.label } 105 | }) 106 | 107 | expect(onChangeFn).toHaveBeenCalled() 108 | 109 | expect(asFragment()).toMatchSnapshot() 110 | }) 111 | 112 | it('Builds correct string on input', () => { 113 | const onChangeFn = jest.fn(editorState => editorState.buildExpression()) 114 | const { asFragment } = render( 115 | 116 | ) 117 | 118 | // Scaffold and expression 119 | const { activeElement } = document 120 | fireEvent.change(activeElement as any, { 121 | target: { value: concatFunction.label } 122 | }) 123 | 124 | // Get scaffolded params 125 | const expressionParamsInput = document.querySelectorAll( 126 | '[data-type="expression-input-root"] input' 127 | ) 128 | expect(expressionParamsInput).toHaveLength(2) 129 | fireEvent.change(expressionParamsInput[0], { 130 | target: { value: adDimension.label } 131 | }) 132 | fireEvent.change(expressionParamsInput[1], { target: { value: '"_"' } }) 133 | 134 | expect(onChangeFn).toHaveBeenCalledTimes(3) 135 | expect(onChangeFn.mock.results[2].value).toEqual('CONCAT (Ad, "_")') 136 | 137 | expect(asFragment()).toMatchSnapshot() 138 | }) 139 | }) 140 | 141 | describe('Keyboard events work properly', () => { 142 | it('Should navigate to the expression root on pressing left on first param', () => { 143 | const { asFragment } = render() 144 | 145 | // Get the root input element 146 | let { activeElement } = document 147 | fireEvent.change(activeElement as any, { 148 | target: { value: concatFunction.key } 149 | }) 150 | 151 | // Active element will be changed to the first param of the expression 152 | activeElement = document.activeElement 153 | 154 | // Fire left arrow key 155 | fireEvent.keyDown(activeElement as any, { keyCode: 37 }) 156 | 157 | // Check that the focus has shifted to the root of the expression 158 | activeElement = document.activeElement 159 | expect(activeElement).toHaveAttribute('data-type', 'expression-root') 160 | 161 | expect(asFragment()).toMatchSnapshot() 162 | }) 163 | it.skip('Should navigate to the next param on pressing right ', () => { 164 | const { asFragment } = render() 165 | 166 | // Get the root input element 167 | let { activeElement } = document 168 | fireEvent.change(activeElement as any, { 169 | target: { value: concatFunction.key } 170 | }) 171 | // Active element will be changed to the first param of the expression 172 | activeElement = document.activeElement 173 | // Fire right arrow key 174 | fireEvent.keyDown(activeElement as any, { keyCode: 39 }) 175 | 176 | // Check that the focus has shifted to the root of the expression 177 | activeElement = document.activeElement 178 | expect(activeElement!.parentElement).toHaveAttribute( 179 | 'data-type', 180 | 'expression-input-root' 181 | ) 182 | const allInputs = document.querySelectorAll( 183 | '[data-type="expression-input-root"]' 184 | ) 185 | const lastInput = allInputs[1].firstElementChild 186 | expect(lastInput).toEqual(activeElement) 187 | 188 | // Check that on pressing right, the focus stays for last param 189 | fireEvent.keyDown(activeElement as any, { keyCode: 39 }) 190 | expect(document.activeElement).toEqual(lastInput) 191 | 192 | expect(asFragment()).toMatchSnapshot() 193 | }) 194 | it('Should delete the expression root on pressing backspace ', () => { 195 | const { asFragment } = render() 196 | 197 | // Get the root input element 198 | let { activeElement } = document 199 | fireEvent.change(activeElement as any, { 200 | target: { value: concatFunction.key } 201 | }) 202 | 203 | // Active element will be changed to the first param of the expression 204 | activeElement = document.activeElement 205 | 206 | // Fire left arrow key 207 | fireEvent.keyDown(activeElement as any, { keyCode: 37 }) 208 | 209 | // Get the expression root 210 | activeElement = document.activeElement 211 | 212 | // Press backspace 213 | fireEvent.keyDown(activeElement as any, { keyCode: 8 }) 214 | // Expression root should have been removed by now 215 | expect(activeElement).not.toBeInTheDocument() 216 | 217 | expect(asFragment()).toMatchSnapshot() 218 | }) 219 | }) 220 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/Extractor.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Extractor builds data structure properly Builds correct string on input 1`] = ` 4 | 5 | 11 | CONCAT( 12 | 38 |
46 | 59 |
62 | "_" 63 |
64 |
65 | ) 66 |
67 |
68 | `; 69 | 70 | exports[`Extractor builds data structure properly Fires the onChange function 1`] = ` 71 | 72 |
79 | 90 |
93 | Ad 94 |
95 |
96 |
97 | `; 98 | 99 | exports[`Extractor renders properly Applies correct data attributes 1`] = ` 100 | 101 |
108 | 119 |
122 | Ad 123 |
124 |
125 |
126 | `; 127 | 128 | exports[`Extractor renders properly Hides options list on blur 1`] = ` 129 | 130 |