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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Two Way Querybuilder
2 |
3 | A simple react component that lets you build queries dynamically on UI. Doesn't depend on any 3-d party libraries.
4 |
5 | 
6 |
7 | ## Installing
8 |
9 | ```bash
10 | npm i react-two-way-querybuilder --save
11 | ```
12 |
13 | ## Using
14 |
15 | Two way query builder is flexible and configurable component with a set of possible options.
16 |
17 | Simple usage:
18 |
19 | ```
20 | import React, { Component } from 'react';
21 | import TwoWayQuerybuilder from 'react-two-way-querybuilder';
22 |
23 | const fields = [
24 | { name: 'firstName', operators: 'all', label: 'First Name', input: { type: 'text' } },
25 | { name: 'lastName', operators: 'all', label: 'Last Name', input: { type: 'text' } },
26 | { name: 'age', operators: 'all', label: 'Age', input: { type: 'text' } },
27 | { name: 'birthDate', operators: 'all', label: 'Birth date', input: { type: 'text' } },
28 | ];
29 |
30 | class App extends Component {
31 |
32 | handleChange(event) {
33 | console.log('query', event.query);
34 | }
35 |
36 | render() {
37 | return (
38 |
39 | );
40 | }
41 | }
42 |
43 | export default App;
44 | ```
45 |
46 | ###Props:
47 |
48 | - **`fields`**: your fields used to build a query
49 | * name: name of the field that would be used in a query
50 | * label: how your field name would be shown in the dropdown
51 | * operators: remove this property or set to 'all' if you want to use all operators for this field, else you can limit them by passing the array of the allowed operators `['=', '<', '>']`
52 | * input: type of the input, possible options are: `text`, `textarea`, `select`. If you are using `select` input type pass options to the object in the following way:
53 | `input: {type: 'select', options: [{value: '1', name: 'one'}, {value: '2', name: 'two'}]}`. Also, it supports validation by passing `pattern` property with regexp pattern and
54 | `errorText` property for validation error message text.
55 |
56 | - **`onChange`**: pass here your function that will be called when data was changed
57 | - **`config`**: configuration object with possible options:
58 | * `query`: pass here prepared query, so UI will be built using it.
59 | * `operators`: array of operators, the default one is:
60 | ```
61 | [
62 | { operator: '=', label: '=' },
63 | { operator: '<>', label: '<>' },
64 | { operator: '<', label: '<' },
65 | { operator: '>', label: '>' },
66 | { operator: '>=', label: '>=' },
67 | { operator: '<=', label: '<=' },
68 | { operator: 'IS NULL', label: 'Null' },
69 | { operator: 'IS NOT NULL', label: 'Not Null' },
70 | { operator: 'IN', label: 'In' },
71 | { operator: 'NOT IN', label: 'Not In' },
72 | ]
73 | ```
74 | * `combinators`: array of combinators, the default one is:
75 | ```
76 | [
77 | { combinator: 'AND', label: 'And' },
78 | { combinator: 'OR', label: 'Or' },
79 | { combinator: 'NOT', label: 'Not' },
80 | ]
81 | ```
82 | * `style`: use this object to redefine styles. Properties:
83 | * `primaryBtn`: used for primary button styles,
84 | * `deleteBtn`: delete button styles,
85 | * `rule`: rule styles,
86 | * `condition`: condition styles,
87 | * `select`: select styles,
88 | * `input`: input styles,
89 | * `txtArea`: text area styles :D
90 | * `error`: error message styling
91 |
92 | - **`buttonsText`**: text of the buttons, you can redefine it for multilanguage support or because you just want. By default used following text:
93 | * addRule: `'Add rule'`,
94 | * addGroup: `'Add group'`,
95 | * clear: `'Clear'`,
96 | * delete: `'Delete'`
97 |
98 | ## Samples
99 |
100 | Visit [DEMO](https://lefortov.github.io/react-two-way-querybuilder) storybook to take a look at basic usage cases:
101 |
102 | - **existing query**:
103 | ```
104 | import React, { Component } from 'react';
105 | import TwoWayQuerybuilder from 'react-two-way-querybuilder';;
106 |
107 | const fields = [
108 | { name: 'firstName', operators: 'all', label: 'First Name', input: { type: 'text' } },
109 | { name: 'lastName', operators: 'all', label: 'Last Name', input: { type: 'text' } },
110 | { name: 'age', operators: 'all', label: 'Age', input: { type: 'text' } },
111 | { name: 'birthDate', operators: 'all', label: 'Birth date', input: { type: 'text' } },
112 | ];
113 |
114 | const config = {
115 | query: "((firstname='Jack' AND lastName='London') OR lastName='Smith')",
116 | };
117 |
118 | class App extends Component {
119 |
120 | handleChange(event) {
121 | console.log('query', event.query);
122 | }
123 |
124 | render() {
125 | return (
126 |
127 | );
128 | }
129 | }
130 |
131 | export default App;
132 | ```
133 |
134 | - **changed input types**:
135 | ```
136 | import React, { Component } from 'react';
137 | import TwoWayQuerybuilder from 'react-two-way-querybuilder';;
138 |
139 | const changedFields = [
140 | { name: 'firstName', operators: 'all', label: 'First Name', input: { type: 'text' } },
141 | { name: 'lastName', operators: 'all', label: 'Last Name', input: {
142 | type: 'select',
143 | options: [
144 | { value: 'Smith', name: 'Smith' },
145 | { value: 'London', name: 'London' },
146 | ] } },
147 | { name: 'age', operators: 'all', label: 'Age',
148 | input: {
149 | type: 'select',
150 | options: [
151 | { value: '28', name: 'twenty eight' },
152 | { value: '30', name: 'thirty' },
153 | ] } },
154 | { name: 'birthDate', operators: 'all', label: 'Birth date', input: { type: 'text' } },
155 | ];
156 |
157 | class App extends Component {
158 |
159 | handleChange(event) {
160 | console.log('query', event.query);
161 | }
162 |
163 | render() {
164 | return (
165 |
166 | );
167 | }
168 | }
169 |
170 | export default App;
171 | ```
172 | - **custom styles**
173 | ```
174 | import React, { Component } from 'react';
175 | import TwoWayQuerybuilder from 'react-two-way-querybuilder';;
176 |
177 | const fields = [
178 | { name: 'firstName', operators: 'all', label: 'First Name', input: { type: 'text' } },
179 | { name: 'lastName', operators: 'all', label: 'Last Name', input: { type: 'text' } },
180 | { name: 'age', operators: 'all', label: 'Age', input: { type: 'text' } },
181 | { name: 'birthDate', operators: 'all', label: 'Birth date', input: { type: 'text' } },
182 | ];
183 |
184 | const styles = {
185 | primaryBtn: 'customPrimaryBtn',
186 | deleteBtn: 'customDeleteBtn',
187 | rule: 'rule',
188 | condition: 'condition',
189 | select: 'querySelect',
190 | input: 'queryInput',
191 | txtArea: 'queryText',
192 | };
193 |
194 | const changedStyles = {
195 | styles,
196 | };
197 |
198 | class App extends Component {
199 |
200 | handleChange(event) {
201 | console.log('query', event.query);
202 | }
203 |
204 | render() {
205 | return (
206 |
207 | );
208 | }
209 | }
210 |
211 | export default App;
212 | ```
213 | - **validation**
214 | ```
215 | import React, { Component } from 'react';
216 | import TwoWayQuerybuilder from 'react-two-way-querybuilder';;
217 |
218 | const validationFields = [
219 | { name: 'firstName', operators: 'all', label: 'First Name', input: {
220 | type: 'text', errorText: 'Only letters allowed', pattern: new RegExp("[a-z]+", "gi") } },
221 | { name: 'lastName', operators: 'all', label: 'Last Name', input: {
222 | type: 'text', errorText: 'Only letters allowed', pattern: new RegExp("[a-z]+", "gi") } },
223 | { name: 'age', operators: 'all', label: 'Age', input: {
224 | type: 'text', errorText: 'Only nubmers allowed', pattern: new RegExp('[0-9]+', 'gi') } },
225 | { name: 'birthDate', operators: 'all', label: 'Birth date', input: {
226 | type: 'text', errorText: 'Only nubmers allowed', pattern: new RegExp('[0-9]+', 'gi') }
227 | },
228 | ];
229 |
230 | class App extends Component {
231 |
232 | handleChange(event) {
233 | console.log('query', event.query);
234 | }
235 |
236 | render() {
237 | return (
238 |
239 | );
240 | }
241 | }
242 |
243 | export default App;
244 | ```
245 |
246 | ##License
247 |
248 | React-two-way-quierybuidler is MIT licensed
249 |
--------------------------------------------------------------------------------
/blob/builder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Lefortov/react-two-way-querybuilder/2aca623849002c7a22b3dbe87df6e77086c3266b/blob/builder.jpg
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-two-way-querybuilder",
3 | "version": "1.0.8",
4 | "description": "React Two Way Querybuilder Component",
5 | "author": "Vladimir Ostapenko",
6 | "homepage": "https://github.com/Lefortov/react-two-way-querybuilder",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Lefortov/react-two-way-querybuilder.git"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/Lefortov/react-two-way-querybuilder/issues"
13 | },
14 | "license": "MIT",
15 | "scripts": {
16 | "prepublish": ". ./.scripts/prepublish.sh",
17 | "lint": "eslint src",
18 | "lintfix": "eslint src --fix",
19 | "testonly": "mocha --require .scripts/mocha_runner src/**/tests/**/*.js",
20 | "test": "npm run lint && npm run testonly",
21 | "test-watch": "npm run testonly -- --watch --watch-extensions js",
22 | "storybook": "start-storybook -p 9010",
23 | "publish-storybook": "bash .scripts/publish_storybook.sh",
24 | "build-storybook": "build-storybook -c .storybook -o .out",
25 | "deploy-storybook": "storybook-to-ghpages"
26 | },
27 | "devDependencies": {
28 | "@kadira/storybook": "^2.35.3",
29 | "@kadira/storybook-deployer": "^1.2.0",
30 | "babel-cli": "^6.14.0",
31 | "babel-core": "^6.14.0",
32 | "babel-eslint": "^6.1.2",
33 | "babel-loader": "^6.2.5",
34 | "babel-plugin-transform-runtime": "^6.15.0",
35 | "babel-polyfill": "^6.13.0",
36 | "babel-preset-es2015": "^6.5.0",
37 | "babel-preset-react": "^6.5.0",
38 | "babel-preset-react-app": "^0.2.1",
39 | "babel-preset-stage-2": "^6.5.0",
40 | "chai": "^3.5.0",
41 | "enzyme": "^2.2.0",
42 | "eslint": "^3.6.0",
43 | "eslint-config-airbnb": "^12.0.0",
44 | "eslint-plugin-import": "^1.16.0",
45 | "eslint-plugin-jsx-a11y": "^2.2.2",
46 | "eslint-plugin-react": "^6.3.0",
47 | "git-url-parse": "^6.0.1",
48 | "jsdom": "^9.5.0",
49 | "mocha": "^3.0.2",
50 | "raw-loader": "^0.5.1",
51 | "react": "^15.3.2",
52 | "react-addons-test-utils": "^15.3.2",
53 | "react-dom": "^15.3.2",
54 | "sinon": "^1.17.6"
55 | },
56 | "peerDependencies": {
57 | "react": "^0.14.7 || ^15.0.0"
58 | },
59 | "dependencies": {
60 | "babel-runtime": "^6.11.6",
61 | "prop-types": "^15.5.8"
62 | },
63 | "main": "dist/index.js",
64 | "engines": {
65 | "npm": "^3.0.0"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Condition.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import TreeHelper from './helpers/TreeHelper';
4 | import Rule from './Rule';
5 |
6 | class Condition extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.treeHelper = new TreeHelper(this.props.data);
10 | this.node = this.treeHelper.getNodeByName(this.props.nodeName);
11 | this.state = {
12 | data: this.node,
13 | };
14 | this.addRule = this.addRule.bind(this);
15 | this.addCondition = this.addCondition.bind(this);
16 | this.handleDelete = this.handleDelete.bind(this);
17 | this.handleChildUpdate = this.handleChildUpdate.bind(this);
18 | this.combinatorChange = this.combinatorChange.bind(this);
19 | this.styles = this.props.config.styles;
20 | }
21 |
22 | addRule() {
23 | const data = this.state.data;
24 | const nodeName = this.treeHelper.generateNodeName(this.state.data);
25 | data.rules.push({
26 | field: this.props.fields[0].name,
27 | operator: this.props.config.operators[0].operator,
28 | value: '',
29 | nodeName });
30 | this.setState({ data });
31 | this.props.onChange(this.props.data);
32 | }
33 |
34 | addCondition() {
35 | const data = this.state.data;
36 | const nodeName = this.treeHelper.generateNodeName(this.state.data);
37 | data.rules.push({
38 | combinator: this.props.config.combinators[0].combinator,
39 | nodeName,
40 | rules: [] });
41 | this.setState({ data });
42 | this.props.onChange(this.props.data);
43 | }
44 |
45 | handleDelete(nodeName) {
46 | this.treeHelper.removeNodeByName(nodeName);
47 | this.props.onChange(this.props.data);
48 | }
49 |
50 | handleChildUpdate() {
51 | const node = this.treeHelper.getNodeByName(this.props.nodeName);
52 | this.setState({ data: node });
53 | this.props.onChange(this.props.data);
54 | }
55 |
56 | combinatorChange(event) {
57 | this.node.combinator = event.target.value;
58 | this.props.onChange(this.props.data);
59 | }
60 |
61 | render() {
62 | return (
63 |
64 |
73 |
76 |
79 | {this.props.nodeName !== '1'
80 | ?
84 | : null}
85 | {this
86 | .state
87 | .data
88 | .rules
89 | .map((rule, index) => {
90 | if (rule.field) {
91 | return ();
100 | } else {
101 | return ();
109 | }
110 | })}
111 |
112 | );
113 | }
114 | }
115 |
116 | Condition.propTypes = {
117 | buttonsText: PropTypes.object.isRequired,
118 | config: PropTypes.object.isRequired,
119 | data: PropTypes.object.isRequired,
120 | fields: PropTypes.array.isRequired,
121 | nodeName: PropTypes.string.isRequired,
122 | onChange: PropTypes.func,
123 | };
124 |
125 | export default Condition;
126 |
--------------------------------------------------------------------------------
/src/Rule.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import TreeHelper from './helpers/TreeHelper';
4 |
5 | const defaultErrorMsg = 'Input value is not correct';
6 |
7 | const isValueCorrect = (pattern, value) => {
8 | const newPattern = new RegExp(pattern);
9 | const match = newPattern.exec(value);
10 | return match === null;
11 | };
12 |
13 | class Rule extends React.Component {
14 | constructor(props) {
15 | super(props);
16 | this.getFieldByName = this.getFieldByName.bind(this);
17 | this.generateRuleObject = this.generateRuleObject.bind(this);
18 | this.onFieldChanged = this.onFieldChanged.bind(this);
19 | this.onOperatorChanged = this.onOperatorChanged.bind(this);
20 | this.onInputChanged = this.onInputChanged.bind(this);
21 | this.getInputTag = this.getInputTag.bind(this);
22 | this.handleDelete = this.handleDelete.bind(this);
23 | this.treeHelper = new TreeHelper(this.props.data);
24 | this.node = this.treeHelper.getNodeByName(this.props.nodeName);
25 | this.styles = this.props.styles;
26 | this.state = {
27 | currField: this.generateRuleObject(this.props.fields[0], this.node),
28 | validationError: false,
29 | };
30 | }
31 |
32 | componentWillReceiveProps(nextProps) {
33 | this.node = this.treeHelper.getNodeByName(nextProps.nodeName);
34 | }
35 |
36 | onFieldChanged(event) {
37 | this.node.field = event.target.value;
38 | const field = this.getFieldByName(event.target.value);
39 | const rule = this.generateRuleObject(field, this.node);
40 | this.setState({ currField: rule });
41 | this.props.onChange();
42 | }
43 |
44 | onOperatorChanged(event) {
45 | this.node.operator = event.target.value;
46 | const field = this.getFieldByName(this.node.field);
47 | const rule = this.generateRuleObject(field, this.node);
48 | this.setState({ currField: rule });
49 | this.props.onChange();
50 | }
51 |
52 | onInputChanged(event) {
53 | const pattern = this.state.currField.input.pattern;
54 | if (pattern) {
55 | this.setState({ validationError: isValueCorrect(pattern, event.target.value) });
56 | }
57 | this.node.value = event.target.value;
58 | const field = this.getFieldByName(this.node.field);
59 | const rule = this.generateRuleObject(field, this.node);
60 | this.setState({ currField: rule });
61 | this.props.onChange();
62 | }
63 |
64 | getFieldByName(name) {
65 | return this.props.fields.find(x => x.name === name);
66 | }
67 |
68 | getInputTag(inputType) {
69 | const errorText = this.state.currField.input.errorText;
70 |
71 | switch (inputType) {
72 | case 'textarea': return (
73 |
74 |
78 | {
79 | this.state.validationError
80 | ?
{errorText || defaultErrorMsg}
81 | : null
82 | }
83 |
);
84 | case 'select': return (
85 | );
89 | default: return (
90 |
91 |
96 | {
97 | this.state.validationError
98 | ?
{errorText || defaultErrorMsg}
99 | : null
100 | }
101 |
);
102 | }
103 | }
104 |
105 | generateRuleObject(field, node) {
106 | const rule = {};
107 | rule.input = field.input;
108 | node = node ? node : this.treeHelper.getNodeByName(this.props.nodeName);
109 | rule.input.value = node.value;
110 | if (!field.operators || typeof (field.operators) === 'string') {
111 | rule.operators = this.props.operators;
112 | return rule;
113 | }
114 | const ruleOperators = [];
115 | for (let i = 0, length = field.operators.length; i < length; i += 1) {
116 | for (let opIndex = 0, opLength = this.props.operators.length; opIndex < opLength; opIndex += 1) {
117 | if (field.operators[i] === this.props.operators[opIndex].operator) {
118 | ruleOperators.push(this.props.operators[opIndex]);
119 | }
120 | }
121 | }
122 | rule.operators = ruleOperators;
123 | return rule;
124 | }
125 |
126 | handleDelete() {
127 | this.treeHelper.removeNodeByName(this.props.nodeName);
128 | this.props.onChange();
129 | }
130 |
131 | render() {
132 | return (
133 |
134 |
143 |
152 | {this.getInputTag(this.state.currField.input.type)}
153 |
158 |
159 | );
160 | }
161 | }
162 |
163 | Rule.propTypes = {
164 | buttonsText: PropTypes.object,
165 | data: PropTypes.object.isRequired,
166 | fields: PropTypes.array.isRequired,
167 | nodeName: PropTypes.string.isRequired,
168 | onChange: PropTypes.func,
169 | operators: PropTypes.array.isRequired,
170 | styles: PropTypes.object.isRequired,
171 | };
172 |
173 | export default Rule;
174 |
--------------------------------------------------------------------------------
/src/TwoWayQuerybuilder.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Condition from './Condition';
4 | import QueryParser from './helpers/QueryParser';
5 | import '../styles.css';
6 |
7 | function buildDefaultConfig(config) {
8 | const defConfig = config || {};
9 | defConfig.query = defConfig.query ? defConfig.query : '()';
10 | defConfig.operators = defConfig.operators ? defConfig.operators :
11 | [
12 | { operator: '=', label: '=' },
13 | { operator: '<>', label: '<>' },
14 | { operator: '<', label: '<' },
15 | { operator: '>', label: '>' },
16 | { operator: '>=', label: '>=' },
17 | { operator: '<=', label: '<=' },
18 | { operator: 'is null', label: 'Null' },
19 | { operator: 'is not null', label: 'Not Null' },
20 | { operator: 'in', label: 'In' },
21 | { operator: 'not in', label: 'Not In' },
22 | ];
23 | defConfig.combinators = defConfig.combinators ? defConfig.combinators :
24 | [
25 | { combinator: 'AND', label: 'And' },
26 | { combinator: 'OR', label: 'Or' },
27 | { combinator: 'NOT', label: 'Not' },
28 | ];
29 | defConfig.animation = defConfig.animation ? defConfig.animation : 'none';
30 | defConfig.styles = defConfig.styles ? defConfig.styles : {
31 | primaryBtn: 'queryButtonPrimary',
32 | deleteBtn: 'queryButtonDelete',
33 | rule: 'rule',
34 | condition: 'condition',
35 | select: 'querySelect',
36 | input: 'queryInput',
37 | txtArea: 'queryText',
38 | error: 'error',
39 | };
40 | return defConfig;
41 | }
42 |
43 | function fillDefaultButtonsText(buttonsText) {
44 | const defBtnText = buttonsText || {};
45 | defBtnText.addRule = defBtnText.addRule ? defBtnText.addRule : 'Add rule';
46 | defBtnText.addGroup = defBtnText.addGroup ? defBtnText.addGroup : 'Add group';
47 | defBtnText.clear = defBtnText.clear ? defBtnText.clear : 'Clear';
48 | defBtnText.delete = defBtnText.delete ? defBtnText.delete : 'Delete';
49 | return defBtnText;
50 | }
51 |
52 | class TwoWayQuerybuilder extends React.Component {
53 | constructor(props) {
54 | super(props);
55 | this.config = buildDefaultConfig(props.config);
56 | this.buttonsText = fillDefaultButtonsText(props.buttonsText);
57 | const defaultData = {
58 | combinator: this.config.combinators[0].combinator,
59 | nodeName: '1',
60 | rules: [],
61 | };
62 | this.state = {
63 | data: this.config.query === '()' ? defaultData : QueryParser.parseToData(this.config.query, this.config),
64 | query: this.config.query === '()' ? null : this.config.query,
65 | };
66 | this.handleChange = this.handleChange.bind(this);
67 | }
68 |
69 | handleChange(data) {
70 | const queryObj = {};
71 | queryObj.data = data;
72 | queryObj.query = QueryParser.parseToQuery(data);
73 | this.setState({ query: queryObj.query });
74 | if (this.props.onChange) {
75 | this.props.onChange(queryObj);
76 | }
77 | }
78 |
79 | render() {
80 | return (
81 |
89 |
);
90 | }
91 | }
92 |
93 | TwoWayQuerybuilder.propTypes = {
94 | buttonsText: PropTypes.object,
95 | config: PropTypes.object,
96 | fields: PropTypes.array.isRequired,
97 | onChange: PropTypes.func,
98 | };
99 |
100 | TwoWayQuerybuilder.defaultProps = {
101 | buttonsText: {},
102 | };
103 |
104 | export default TwoWayQuerybuilder;
105 |
--------------------------------------------------------------------------------
/src/helpers/ASTree.js:
--------------------------------------------------------------------------------
1 | import TreeNode from './TreeNode';
2 |
3 | export default class ASTree {
4 | static buildTree(tokens, combinators) {
5 | let tree;
6 | let currentNode = null;
7 | for (let i = 0, length = tokens.length; i < length; i += 1) {
8 | if (tokens[i] === '(') {
9 | const node = new TreeNode(tokens[i], currentNode, []);
10 | if (!currentNode) {
11 | tree = node;
12 | } else {
13 | currentNode.addChild(node);
14 | }
15 | currentNode = node;
16 | }
17 |
18 | const currCombinator = combinators.find(x => x.combinator === tokens[i]);
19 | if (currCombinator && currentNode.value !== tokens[i]) {
20 | const node = new TreeNode(tokens[i], currentNode, []);
21 | currentNode.addChild(node);
22 | currentNode = node;
23 | }
24 |
25 | if (tokens[i].field) {
26 | if (!combinators.find(x => x.combinator === currentNode.value)) {
27 | const combinator = this.getNearestCombinator(tokens, i, combinators);
28 | const combNode = new TreeNode(combinator, currentNode, []);
29 | currentNode.addChild(combNode);
30 | currentNode = combNode;
31 | }
32 | const node = new TreeNode(tokens[i], currentNode, []);
33 | currentNode.addChild(node);
34 | }
35 |
36 | if (tokens[i] === ')') {
37 | while (currentNode.value !== '(') {
38 | currentNode = currentNode.parent;
39 | }
40 | currentNode.value += ')';
41 | currentNode = currentNode.parent;
42 | }
43 | }
44 | return tree;
45 | }
46 |
47 | static getNearestCombinator(tokens, index, combinators) {
48 | for (let i = index, length = tokens.length; i < length; i += 1) {
49 | if (combinators.find(x => x.combinator === tokens[i])) {
50 | return tokens[i];
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/helpers/QueryParser.js:
--------------------------------------------------------------------------------
1 | import TreeHelper from './TreeHelper';
2 | import ASTree from './ASTree';
3 |
4 | export default class QueryParser {
5 |
6 | static parseToQuery(data, query) {
7 | query = '(';
8 | for (let i = 0, length = data.rules.length; i < length; i += 1) {
9 | if (!data.rules[i].combinator) {
10 | query += `${data.rules[i].field} ${data.rules[i].operator} '${data.rules[i].value}'`;
11 | if (i !== length - 1 && !data.rules[i + 1].combinator) {
12 | query += ` ${data.combinator} `;
13 | }
14 | } else {
15 | query += ` ${data.combinator} ${this.parseToQuery(data.rules[i], query)}`;
16 | }
17 | }
18 | return `${query})`;
19 | }
20 |
21 | static parseToData(query, config) {
22 | const data = null;
23 | const tokens = this.getTokensArray(query, config.combinators, config.operators);
24 | const asTree = ASTree.buildTree(tokens, config.combinators);
25 | return this.convertSyntaxTreeToData(asTree, data, config.combinators, '1', '1');
26 | }
27 |
28 | static convertSyntaxTreeToData(element, data, combinators, nodeName, combNodeName) {
29 | data = data ? data : {};
30 | let newCombName = combNodeName;
31 | const firstCombinator = this.getFirstCombinator(element, combinators);
32 | const treeHelper = new TreeHelper(data);
33 | const newCombinator = {
34 | combinator: firstCombinator ? firstCombinator.value : combinators[0].combinator,
35 | nodeName,
36 | rules: [],
37 | };
38 | let currElement = treeHelper.getNodeByName(combNodeName);
39 | if (element.value === '()' && !element.parent) {
40 | data = newCombinator;
41 | currElement = data;
42 | } else if (element.value === '()' && element.parent) {
43 | currElement.rules.push(newCombinator);
44 | newCombName = nodeName;
45 | } else if (element.value && element.value.field) {
46 | const newRule = {
47 | field: element.value.field,
48 | operator: element.value.operator,
49 | value: element.value.value,
50 | nodeName,
51 | };
52 | currElement.rules.push(newRule);
53 | }
54 | for (let i = 0; i < element.children.length; i += 1) {
55 | this.convertSyntaxTreeToData(element.children[i], data, combinators, `${newCombName}/${currElement.rules.length + 1}`, newCombName);
56 | }
57 | return data;
58 | }
59 |
60 | static getTokensArray(query, combinators, operators) {
61 | const combinatorsIndexes = this.getCombinatorsIndexes(query, combinators);
62 | const tokens = [];
63 | let token = '';
64 | for (let i = 0, length = query.length; i < length; i += 1) {
65 | const combinatorIndexes = combinatorsIndexes.find(x => x.start === i);
66 | if (combinatorIndexes) {
67 | const combinator = query.substring(combinatorIndexes.start, combinatorIndexes.end);
68 | token = this.pushTokenIfNotEmpty(token, tokens, operators);
69 | tokens.push(combinator);
70 | i = combinatorIndexes.end;
71 | } else if (query[i] === '(' || query[i] === ')') {
72 | token = this.pushTokenIfNotEmpty(token, tokens, operators);
73 | tokens.push(query[i]);
74 | } else {
75 | token += query[i];
76 | }
77 | }
78 | return tokens;
79 | }
80 |
81 | static pushTokenIfNotEmpty(token, array, operators) {
82 | token = token.trim();
83 | if (token) {
84 | array.push(this.createTokenObject(token, operators));
85 | }
86 | return '';
87 | }
88 |
89 | static createTokenObject(token, operators) {
90 | const operatorsPattern = this.getSearchPattern(operators, 'operator');
91 | const matches = this.matchAll(token, operatorsPattern);
92 | const mathesLength = matches.map(el => el.value).join('').length;
93 | const operatorEndIndex = matches[0].index + mathesLength;
94 | return {
95 | field: token.substring(0, matches[0].index).trim(),
96 | operator: token.substring(matches[0].index, operatorEndIndex),
97 | value: token.substring(operatorEndIndex, token.length).replace(/[']+/g, '').trim(),
98 | };
99 | }
100 |
101 | static matchAll(str, regex) {
102 | const res = [];
103 | let m;
104 | if (regex.global) {
105 | while (m = regex.exec(str)) {
106 | res.push({ value: m[0], index: m.index });
107 | }
108 | } else if (m = regex.exec(str)) {
109 | res.push({ value: m[0], index: m.index });
110 | }
111 | return res;
112 | }
113 |
114 | static getCombinatorsIndexes(query, combinators) {
115 | const combinatorsIndexes = [];
116 | const combinatorsPattern = this.getSearchPattern(combinators, 'combinator');
117 | let match;
118 | while ((match = combinatorsPattern.exec(query)) !== null) {
119 | combinatorsIndexes.push({ start: match.index, end: combinatorsPattern.lastIndex });
120 | }
121 | return combinatorsIndexes;
122 | }
123 |
124 | static getSearchPattern(searchValues, name) {
125 | let pattern = '';
126 | for (let i = 0; i < searchValues.length; i += 1) {
127 | pattern += `|${searchValues[i][name]}`;
128 | }
129 | // To remove first | character
130 | pattern = pattern.slice(1);
131 | return new RegExp(pattern, 'g');
132 | }
133 |
134 | static getFirstCombinator(element, combinators) {
135 | let foundCombinator = element.children.find(x => combinators.find(y => y.combinator === x.value));
136 | if (!foundCombinator) {
137 | for (let i = 0; i < element.children.length; i += 1) {
138 | foundCombinator = this.getFirstCombinator(element.children[i], combinators);
139 | }
140 | }
141 | return foundCombinator;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/helpers/TreeHelper.js:
--------------------------------------------------------------------------------
1 | export default class TreeHelper {
2 | constructor(data) {
3 | this.data = data;
4 | }
5 |
6 | generateNodeName(node) {
7 | return `${node.nodeName}/${node.rules.length + 1}`;
8 | }
9 |
10 | removeNodeByName(index) {
11 | this.removeNode(this.data, index, 0);
12 | return this.data;
13 | }
14 |
15 | getNodeByName(name) {
16 | return this.getNode(this.data, name);
17 | }
18 |
19 | removeNode(data, name) {
20 | for (const property in data) {
21 | if (data.hasOwnProperty(property)) {
22 | if (property === 'rules') {
23 | for (let i = 0; i < data.rules.length; i += 1) {
24 | if (data.rules[i].nodeName === name) {
25 | data.rules.splice(i, 1);
26 | return;
27 | } else if (data.rules[i].combinator) {
28 | this.removeNode(data.rules[i], name);
29 | }
30 | }
31 | }
32 | }
33 | }
34 | }
35 |
36 | getNode(treeData, name) {
37 | if (name === '1') {
38 | return treeData;
39 | }
40 | for (const property in treeData) {
41 | if (treeData.hasOwnProperty(property)) {
42 | if (property === 'rules') {
43 | let node = null;
44 | for (let i = 0; i < treeData.rules.length; i += 1) {
45 | if (treeData.rules[i].nodeName === name) {
46 | node = treeData.rules[i];
47 | } else if (treeData.rules[i].combinator && node === null) {
48 | node = this.getNode(treeData.rules[i], name);
49 | }
50 | }
51 | return node;
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/helpers/TreeNode.js:
--------------------------------------------------------------------------------
1 | export default class TreeNode {
2 | constructor(value, parent, children) {
3 | this._value = value;
4 | this._parent = parent;
5 | this._children = children;
6 | }
7 |
8 | get value() {
9 | return this._value;
10 | }
11 |
12 | set value(value) {
13 | if (value) {
14 | this._value = value;
15 | }
16 | }
17 |
18 | get parent() {
19 | return this._parent;
20 | }
21 |
22 | set parent(parent) {
23 | if (parent) {
24 | this._parent = parent;
25 | }
26 | }
27 |
28 | get children() {
29 | return this._children;
30 | }
31 |
32 | set children(children) {
33 | if (children) {
34 | this._children = children;
35 | }
36 | }
37 |
38 | addChild(child) {
39 | this._children.push(child);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import TwoWayQuerybuilder from './TwoWayQuerybuilder';
2 |
3 | export default TwoWayQuerybuilder;
4 |
--------------------------------------------------------------------------------
/src/tests/index.js:
--------------------------------------------------------------------------------
1 | import assert from 'assert';
2 | import QueryParser from '../helpers/QueryParser';
3 | import ASTree from '../helpers/ASTree';
4 |
5 | const { describe, it } = global;
6 |
7 | const combinators = [
8 | { combinator: 'AND', label: 'And' },
9 | { combinator: 'OR', label: 'Or' },
10 | { combinator: 'NOT', label: 'Not' },
11 | ];
12 |
13 | const operators = [
14 | { operator: '=', label: '=' },
15 | { operator: '<>', label: '<>' },
16 | { operator: '<', label: '<' },
17 | { operator: '>', label: '>' },
18 | { operator: '>=', label: '>=' },
19 | { operator: '<=', label: '<=' },
20 | { operator: 'IS NULL', label: 'Null' },
21 | { operator: 'IS NOT NULL', label: 'Not Null' },
22 | { operator: 'IN', label: 'In' },
23 | { operator: 'NOT IN', label: 'Not In' },
24 | ];
25 |
26 | describe('Query Parser', function () {
27 | describe('GetCombinatorsIndexes', function () {
28 | it('should return AND and OR substrings', function () {
29 | const query = "((Firstname='kek' AND Firstname='kek1') OR Firstname='Kek3')";
30 | const result = QueryParser.getCombinatorsIndexes(query, combinators);
31 | const expectedFirstOeprator = query.substr(result[0].start, result[0].end - result[0].start);
32 | const expectedSecondOperator = query.substr(result[1].start, result[1].end - result[1].start);
33 | assert.equal(expectedFirstOeprator, 'AND');
34 | assert.equal(expectedSecondOperator, 'OR');
35 | });
36 | });
37 |
38 | describe('GetTokenObject', function () {
39 | it('should return token object', function () {
40 | const token = "Firstname='kek'";
41 | const result = QueryParser.createTokenObject(token, operators);
42 | assert.equal(result.field, 'Firstname');
43 | assert.equal(result.operator, '=');
44 | assert.equal(result.value, "kek");
45 | });
46 | it('should return token object without extra space', function () {
47 | const token = "Firstname IN 'kek, john'";
48 | const result = QueryParser.createTokenObject(token, operators);
49 | assert.equal(result.field, 'Firstname');
50 | assert.equal(result.operator, 'IN');
51 | assert.equal(result.value, "kek, john");
52 | });
53 | });
54 |
55 |
56 | describe('Get tokens array', function () {
57 | it('should return token array', function () {
58 | const query = "((Firstname='kek' AND Firstname='kek1') OR Firstname='kek3')";
59 | const result = QueryParser.getTokensArray(query, combinators, operators);
60 | const expectedResult = [
61 | '(',
62 | '(',
63 | { field: 'Firstname', operator: '=', value: "kek" },
64 | 'AND',
65 | { field: 'Firstname', operator: '=', value: "kek1" },
66 | ')',
67 | 'OR',
68 | { field: 'Firstname', operator: '=', value: "kek3" },
69 | ')',
70 | ];
71 | assert.deepEqual(result, expectedResult);
72 | });
73 | });
74 |
75 | describe('get first combinator', function () {
76 | it('should return AND', function () {
77 | const query = "((Firstname='kek' AND Firstname='kek1'))";
78 | const tokens = QueryParser.getTokensArray(query, combinators, operators);
79 | const tree = ASTree.buildTree(tokens, combinators);
80 | const expectedResult = 'AND';
81 | const result = QueryParser.getFirstCombinator(tree, combinators);
82 | assert.equal(result.value, expectedResult);
83 | });
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | .queryButton:active{
2 | outline: none;
3 | }
4 |
5 | .queryButtonPrimary{
6 | font-family: Roboto, sans-serif;
7 | -moz-osx-font-smoothing: grayscale;
8 | -webkit-font-smoothing: antialiased;
9 | font-size: 0.875rem;
10 | font-weight: 500;
11 | letter-spacing: 0.04em;
12 | line-height: 1.5rem;
13 | color: rgba(0, 0, 0, 0.87);
14 | color: var(--mdc-theme-text-primary-on-light, rgba(0, 0, 0, 0.87));
15 | display: inline-block;
16 | position: relative;
17 | min-width: 64px;
18 | height: 36px;
19 | padding: 0 16px;
20 | border: none;
21 | border-radius: 2px;
22 | outline: none;
23 | background: transparent;
24 | font-size: 14px;
25 | font-weight: 500;
26 | line-height: 36px;
27 | text-align: center;
28 | text-transform: uppercase;
29 | vertical-align: middle;
30 | box-sizing: border-box;
31 | -webkit-appearance: none;
32 | -webkit-tap-highlight-color: transparent;
33 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
34 | -webkit-transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1);
35 | transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1);
36 | will-change: box-shadow;
37 | min-width: 88px;
38 | margin-left: 1rem;
39 | cursor: pointer;
40 | background-color: #3f51b5;
41 | background-color: var(--mdc-theme-primary, #3f51b5);
42 | color: white;
43 | color: var(--mdc-theme-text-primary-on-primary, white);
44 | }
45 |
46 | .queryButtonPrimary:active {
47 | box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12);
48 | }
49 |
50 | .queryButtonDelete:active{
51 | box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12)
52 | }
53 |
54 | .queryButtonDelete{
55 | font-family: Roboto, sans-serif;
56 | -moz-osx-font-smoothing: grayscale;
57 | -webkit-font-smoothing: antialiased;
58 | font-size: 0.875rem;
59 | font-weight: 500;
60 | letter-spacing: 0.04em;
61 | line-height: 1.5rem;
62 | color: rgba(0, 0, 0, 0.87);
63 | color: var(--mdc-theme-text-primary-on-light, rgba(0, 0, 0, 0.87));
64 | display: inline-block;
65 | position: relative;
66 | min-width: 64px;
67 | height: 36px;
68 | padding: 0 16px;
69 | border: none;
70 | border-radius: 2px;
71 | outline: none;
72 | background: transparent;
73 | font-size: 14px;
74 | font-weight: 500;
75 | line-height: 36px;
76 | text-align: center;
77 | text-transform: uppercase;
78 | vertical-align: middle;
79 | box-sizing: border-box;
80 | -webkit-appearance: none;
81 | -webkit-tap-highlight-color: transparent;
82 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
83 | -webkit-transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1);
84 | transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1);
85 | will-change: box-shadow;
86 | min-width: 88px;
87 | margin-left: 1rem;
88 | cursor: pointer;
89 | color: #ff4081;
90 | color: var(--mdc-theme-accent, #ff4081);
91 | background-color: #ff4081;
92 | background-color: var(--mdc-theme-accent, #ff4081);
93 | color: white;
94 | color: var(--mdc-theme-text-primary-on-accent, white);
95 | }
96 |
97 | .rule{
98 | display: flex;
99 | border-radius: 3px;
100 | color: #9E9E9E;
101 | background: white;
102 | box-shadow: 0px 4px 5px -2px rgba(0, 0, 0, 0.2), 0px 7px 10px 1px rgba(0, 0, 0, 0.14), 0px 2px 16px 1px rgba(0, 0, 0, 0.12);
103 | margin-left: 1rem;
104 | padding: 0.5rem;
105 | margin-top: 0.2rem;
106 | width: 85%;
107 | -webkit-animation: fade 1s;
108 | animation: fade 1s;
109 | opacity: 1;
110 | }
111 |
112 | .rule *{
113 | margin-left: 0.5rem;
114 | }
115 |
116 | .condition{
117 | margin-left: 1rem;
118 | padding: 0.5rem;
119 | margin-top: 0.2rem;
120 | border-radius: 3px;
121 | color: #9E9E9E;
122 | background: white;
123 | box-shadow: 0px 4px 5px -2px rgba(0, 0, 0, 0.2), 0px 7px 10px 1px rgba(0, 0, 0, 0.14), 0px 2px 16px 1px rgba(0, 0, 0, 0.12);
124 | width: 85%;
125 | -webkit-animation: fade 1s;
126 | animation: fade 1s;
127 | opacity: 1;
128 | }
129 |
130 | .querySelect{
131 | font-family: Roboto, sans-serif;
132 | -moz-osx-font-smoothing: grayscale;
133 | -webkit-font-smoothing: antialiased;
134 | font-size: 1rem;
135 | font-weight: 400;
136 | letter-spacing: 0.04em;
137 | line-height: 1.75rem;
138 | color: rgba(0, 0, 0, 0.87);
139 | color: var(--mdc-theme-text-primary-on-light, rgba(0, 0, 0, 0.87));
140 | padding-left: 0;
141 | padding-right: 24px;
142 | -webkit-appearance: none;
143 | -moz-appearance: none;
144 | appearance: none;
145 | display: -webkit-inline-box;
146 | display: -ms-inline-flexbox;
147 | display: inline-flex;
148 | -webkit-box-align: center;
149 | -ms-flex-align: center;
150 | align-items: center;
151 | -webkit-box-pack: start;
152 | -ms-flex-pack: start;
153 | justify-content: flex-start;
154 | max-width: calc(100% - 24px);
155 | height: 32px;
156 | -webkit-transition: border-bottom-color 150ms 0ms cubic-bezier(0.4, 0, 1, 1), background-color 150ms 0ms cubic-bezier(0.4, 0, 1, 1);
157 | transition: border-bottom-color 150ms 0ms cubic-bezier(0.4, 0, 1, 1), background-color 150ms 0ms cubic-bezier(0.4, 0, 1, 1);
158 | border: none;
159 | border-bottom: 1px solid rgba(0, 0, 0, 0.12);
160 | border-radius: 0;
161 | background: none;
162 | background-repeat: no-repeat;
163 | background-position: right center;
164 | background-image: url("data:image/svg+xml,");
165 | font-family: Roboto, sans-serif;
166 | font-size: .936rem;
167 | cursor: pointer;
168 | font-family: Roboto, sans-serif;
169 | font-size: 1rem;
170 | font-weight: 400;
171 | letter-spacing: .04em;
172 | }
173 |
174 | .querySelect:focus{
175 | border-bottom-color: #3f51b5;
176 | border-bottom-color: var(--mdc-theme-primary, #3f51b5);
177 | outline: none;
178 | background-color: rgba(0, 0, 0, 0.06);
179 | }
180 |
181 | .queryInput{
182 | color: rgba(0, 0, 0, 0.87);
183 | color: var(--mdc-theme-text-primary-on-light, rgba(0, 0, 0, 0.87));
184 | border: none;
185 | background: none;
186 | font-size: inherit;
187 | -webkit-appearance: none;
188 | -moz-appearance: none;
189 | appearance: none;
190 | -webkit-transition: border-bottom-color 180ms cubic-bezier(0.4, 0, 0.2, 1);
191 | transition: border-bottom-color 180ms cubic-bezier(0.4, 0, 0.2, 1);
192 | border-bottom: 1px solid rgba(0, 0, 0, 0.12);
193 | padding-top: 8px;
194 | padding-bottom: 6px;
195 | }
196 |
197 | .queryInput:focus{
198 | border-color: #3f51b5;
199 | border-color: var(--mdc-theme-primary, #3f51b5);
200 | outline: none;
201 | }
202 |
203 | .queryText{
204 | font-family: Roboto, sans-serif;
205 | -moz-osx-font-smoothing: grayscale;
206 | -webkit-font-smoothing: antialiased;
207 | font-size: 1rem;
208 | letter-spacing: 0.04em;
209 | display: inline-block;
210 | margin-bottom: 8px;
211 | will-change: opacity, transform, color;
212 | display: -webkit-box;
213 | display: -ms-flexbox;
214 | display: flex;
215 | height: initial;
216 | -webkit-transition: none;
217 | transition: none;
218 | }
219 |
220 | @-webkit-keyframes fade {
221 | from {opacity: 0}
222 | to {opacity: 1}
223 | }@keyframes fade {
224 | from {opacity: 0}
225 | to {opacity: 1}
226 | }
227 |
228 | .error {
229 | color: red;
230 | }
231 |
--------------------------------------------------------------------------------