├── .github ├── FUNDING.yml └── renovate.json ├── __mocks__ └── styleMock.js ├── renovate.json ├── example └── src │ ├── scss │ ├── styles.scss │ └── _general.scss │ ├── favicon.ico │ ├── icons │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ └── apple-icon-precomposed.png │ ├── index.js │ ├── index.html │ ├── App.js │ └── constants │ └── qbFields.js ├── target-screen.png ├── query-builder-screen.png ├── src ├── constants │ ├── combinators.js │ ├── dataTypes.js │ ├── fieldTypes.js │ ├── propTypes.js │ └── operators.js ├── __tests__ │ └── QBuilder.test.js ├── assets │ ├── _overrides.scss │ ├── styles.scss │ ├── _rules.scss │ ├── _buttons.scss │ ├── _fields.scss │ └── _slider.scss ├── index.js ├── helpers │ ├── generateField.js │ ├── generateSimpleQuery.js │ ├── generateRule.js │ ├── generateRuleGroup.js │ ├── __tests__ │ │ ├── generateField.test.js │ │ ├── generateRule.test.js │ │ ├── generateRuleGroup.test.js │ │ ├── generateSimpleQuery.test.js │ │ └── utils.test.js │ └── utils.js ├── components │ ├── fields │ │ ├── Input.jsx │ │ ├── Toggle.jsx │ │ ├── Slider.jsx │ │ ├── InputNumber.jsx │ │ ├── AutoComplete.jsx │ │ └── DatePicker.jsx │ ├── __tests__ │ │ ├── FieldContainer.test.js │ │ ├── Slider.test.js │ │ ├── Input.test.js │ │ ├── Toggle.test.js │ │ ├── DropdownSelect.test.js │ │ ├── InputNumber.test.js │ │ ├── AutoComplete.test.js │ │ ├── DatePicker.test.js │ │ ├── Rule.test.js │ │ └── RuleGroup.test.js │ ├── DropdownSelect.jsx │ ├── FieldContainer.jsx │ ├── Rule.jsx │ └── RuleGroup.jsx └── QBuilder.js ├── .npmignore ├── .babelrc ├── setupTests.js ├── .eslintrc.json ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── config ├── webpack.dev.js ├── webpack.prod.js └── webpack.common.js ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: bpk68 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /example/src/scss/styles.scss: -------------------------------------------------------------------------------- 1 | // Load up our project styles 2 | @import './general'; 3 | 4 | -------------------------------------------------------------------------------- /target-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/target-screen.png -------------------------------------------------------------------------------- /example/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/favicon.ico -------------------------------------------------------------------------------- /query-builder-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/query-builder-screen.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon.png -------------------------------------------------------------------------------- /src/constants/combinators.js: -------------------------------------------------------------------------------- 1 | const COMBINATORS = { 2 | AND: 'AND', 3 | OR: 'OR', 4 | }; 5 | 6 | export default COMBINATORS; -------------------------------------------------------------------------------- /example/src/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/favicon-16x16.png -------------------------------------------------------------------------------- /example/src/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/favicon-32x32.png -------------------------------------------------------------------------------- /example/src/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/favicon-96x96.png -------------------------------------------------------------------------------- /example/src/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /example/src/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /example/src/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /example/src/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /example/src/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /example/src/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /example/src/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /example/src/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .cache 4 | __mocks__ 5 | example 6 | src 7 | .babelrc 8 | .eslintrc.json 9 | config 10 | setupTests.js -------------------------------------------------------------------------------- /example/src/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /example/src/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /example/src/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpk68/react-visual-query-builder/HEAD/example/src/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageRules": [ 3 | { 4 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 5 | "automerge": true 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-proposal-class-properties" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/__tests__/QBuilder.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Components 4 | //import QBuilder from '../QBuilder'; 5 | 6 | it('renders without crashing', () => { 7 | // 8 | }); -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import 'jest-enzyme'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | 6 | configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /src/constants/dataTypes.js: -------------------------------------------------------------------------------- 1 | const DATA_TYPES = { 2 | NUMBER: 'number', 3 | STRING: 'string', 4 | TRUE_FALSE: 'bool', 5 | LIST: 'array', 6 | OBJECT: 'object', 7 | DATE_TIME: 'datetime' 8 | }; 9 | 10 | export default DATA_TYPES; -------------------------------------------------------------------------------- /src/assets/_overrides.scss: -------------------------------------------------------------------------------- 1 | 2 | // react-datepicker 3 | .react-datepicker__close-icon { 4 | 5 | &::after { 6 | background-color: transparent; 7 | color: inherit; 8 | font-size: 1em; 9 | font-weight: bold; 10 | } 11 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react", 4 | "react-hooks" 5 | ], 6 | "extends": [ 7 | "react-app" 8 | ], 9 | "rules": { 10 | "react-hooks/rules-of-hooks": "error", 11 | "react-hooks/exhaustive-deps": "warn" 12 | } 13 | } -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | // Styles 5 | // eslint-disable-next-line 6 | import styles from './scss/styles.scss'; 7 | 8 | // Components 9 | import App from './App'; 10 | 11 | ReactDOM.render(, document.getElementById('root')); 12 | -------------------------------------------------------------------------------- /src/constants/fieldTypes.js: -------------------------------------------------------------------------------- 1 | const FIELD_TYPES = { 2 | AUTOCOMPLETE_LIST: 'autocomplete list', 3 | NUMBER: 'number', 4 | DATE: 'date picker', 5 | DROPDOWN: 'select', 6 | MULTI_SELECT: 'multi select', 7 | TOGGLE: 'toggle', 8 | SLIDER: 'slider', 9 | TEXT: 'text' 10 | }; 11 | 12 | export default FIELD_TYPES; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Fields and constants 2 | import OPERATORS from './constants/operators'; 3 | import FIELD_TYPES from './constants/fieldTypes'; 4 | import COMBINATORS from './constants/combinators'; 5 | import DATA_TYPES from './constants/dataTypes'; 6 | 7 | // Components 8 | import QBuilder from './QBuilder'; 9 | 10 | export default QBuilder; 11 | export { 12 | OPERATORS, 13 | FIELD_TYPES, 14 | COMBINATORS, 15 | DATA_TYPES, 16 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # parcel 16 | /.cache 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /example/src/scss/_general.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 1em 3em; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } -------------------------------------------------------------------------------- /src/assets/styles.scss: -------------------------------------------------------------------------------- 1 | // Variables 2 | $grey: hsl(0, 0%, 60%); 3 | $dark-grey: hsl(0, 0%, 46%); 4 | $light-grey: hsl(0, 0%, 77%); 5 | $light-blue: hsl(208, 100%, 97%); 6 | $blue: hsl(208, 79%, 64%); 7 | $green: hsl(144, 95%, 36%); 8 | $red: hsl(19, 95%, 36%); 9 | $radius: 5px; 10 | 11 | 12 | // General 13 | 14 | 15 | // Imports 16 | .react-qb { 17 | @import './buttons'; 18 | @import './rules'; 19 | @import './fields'; 20 | @import './overrides'; 21 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [Released] 8 | 9 | ## [1.1.0] 2020-05-05 10 | ### Changed 11 | - Update security vulnerabilities 12 | - Update documentation 13 | 14 | ## [1.0.0] 2020-05-01 15 | ### Changed 16 | - Add initial MVP of the query builder -------------------------------------------------------------------------------- /src/helpers/generateField.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Creates a new field object 4 | * @param {object} [{label: string; name: string; dataType: string; type: string}] 5 | * @returns {object} - field 6 | */ 7 | const generateField = (field = {}) => { 8 | 9 | return { 10 | label: field.label || '', 11 | name: field.name || '', 12 | dataType: field.dataType || '', 13 | type: field.type || '', 14 | }; 15 | }; 16 | 17 | export default generateField; -------------------------------------------------------------------------------- /src/helpers/generateSimpleQuery.js: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid'; 2 | import generateRuleGroup from './generateRuleGroup'; 3 | 4 | /** 5 | * Creates a new query object 6 | * @param {object} [{id: string; rules: array}] 7 | * @returns {object} - query 8 | */ 9 | const generateSimpleQuery = (query = {}) => { 10 | 11 | return { 12 | id: query.id || `q-${nanoid()}`, 13 | rules: query.rules || [generateRuleGroup()] 14 | }; 15 | }; 16 | 17 | export default generateSimpleQuery; -------------------------------------------------------------------------------- /src/helpers/generateRule.js: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid'; 2 | 3 | /** 4 | * Creates a new query rule object 5 | * @param {object} [{id: string; field: string; value: string; operator: string}] 6 | * @returns {object} - query rule 7 | */ 8 | const generateRule = (rule = {}) => { 9 | 10 | return { 11 | id: rule.id || `r-${nanoid()}`, 12 | field: rule.field || '', 13 | value: rule.value || '', 14 | operator: rule.operator || '', 15 | }; 16 | }; 17 | 18 | export default generateRule; -------------------------------------------------------------------------------- /src/helpers/generateRuleGroup.js: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid'; 2 | 3 | /** 4 | * Creates a new rule group object 5 | * @param {object} [{id: string; rules: []; combinator: string; not: boolean}] 6 | * @returns {object} - rule group 7 | */ 8 | const generateRuleGroup = (group = {}) => { 9 | 10 | return { 11 | id: group.id || `g-${nanoid()}`, 12 | rules: group.rules || [], 13 | combinator: group.combinator || '', 14 | not: false 15 | }; 16 | }; 17 | 18 | export default generateRuleGroup; -------------------------------------------------------------------------------- /src/assets/_rules.scss: -------------------------------------------------------------------------------- 1 | .rule-group { 2 | background-color: rgba($light-grey, 0.3); 3 | border: 1px solid $light-grey; 4 | padding: 1em; 5 | border-radius: 5px; 6 | 7 | .rule-group { 8 | margin-left: 1em; 9 | border-width: 3px; 10 | border-style: double; 11 | } 12 | } 13 | 14 | .rule { 15 | background-color: $light-blue; 16 | padding: 0.5em; 17 | border: 1px dashed $blue; 18 | margin: 0.5em 0; 19 | display: flex; 20 | align-items: center; 21 | line-height: 1.2; 22 | 23 | & > * { 24 | margin-right: 0.5em 25 | } 26 | } -------------------------------------------------------------------------------- /src/helpers/__tests__/generateField.test.js: -------------------------------------------------------------------------------- 1 | import generateField from '../generateField'; 2 | 3 | const expectedReturnObj = { 4 | label: '', 5 | name: '', 6 | dataType: '', 7 | type: '' 8 | }; 9 | 10 | it('returns a field object', () => { 11 | expect(generateField()).toEqual(expectedReturnObj); 12 | }); 13 | 14 | it('populates part of the return object using data passed in', () => { 15 | const populatedObj = { 16 | ...expectedReturnObj, 17 | name: 'hello', 18 | type: 'type' 19 | }; 20 | 21 | expect(generateField(populatedObj)).toEqual(populatedObj); 22 | }); -------------------------------------------------------------------------------- /src/helpers/__tests__/generateRule.test.js: -------------------------------------------------------------------------------- 1 | import generateRule from '../generateRule'; 2 | 3 | const expectedReturnObj = { 4 | id: '', 5 | field: '', 6 | value: '', 7 | operator: '' 8 | }; 9 | 10 | it('returns a field object', () => { 11 | expect(generateRule().field).toEqual(expectedReturnObj.field); 12 | }); 13 | 14 | it('populates part of the return object using data passed in', () => { 15 | const populatedObj = { 16 | ...expectedReturnObj, 17 | id: '123-abc', 18 | operator: 'and' 19 | }; 20 | 21 | expect(generateRule(populatedObj)).toEqual(populatedObj); 22 | }); -------------------------------------------------------------------------------- /src/helpers/__tests__/generateRuleGroup.test.js: -------------------------------------------------------------------------------- 1 | import generateRuleGroup from '../generateRuleGroup'; 2 | 3 | const expectedReturnObj = { 4 | id: '', 5 | rules: [], 6 | combinator: '', 7 | not: false 8 | }; 9 | 10 | it('returns a field object', () => { 11 | expect(generateRuleGroup().rules).toEqual(expectedReturnObj.rules); 12 | }); 13 | 14 | it('populates part of the return object using data passed in', () => { 15 | const populatedObj = { 16 | ...expectedReturnObj, 17 | id: '123-abc', 18 | rules: [1, 2, 3] 19 | }; 20 | 21 | expect(generateRuleGroup(populatedObj)).toEqual(populatedObj); 22 | }); -------------------------------------------------------------------------------- /src/helpers/__tests__/generateSimpleQuery.test.js: -------------------------------------------------------------------------------- 1 | import generateRuleGroup from '../generateRuleGroup'; 2 | import generateSimpleQuery from '../generateSimpleQuery'; 3 | 4 | const expectedReturnObj = { 5 | id: '', 6 | rules: [generateRuleGroup()], 7 | target: { type: '', value: '' } 8 | }; 9 | 10 | it('returns a field object', () => { 11 | expect(generateSimpleQuery().target).toEqual(expectedReturnObj.target); 12 | }); 13 | 14 | it('populates part of the return object using data passed in', () => { 15 | const populatedObj = { 16 | ...expectedReturnObj, 17 | id: '123-abc', 18 | rules: [1, 2, 3] 19 | }; 20 | 21 | expect(generateSimpleQuery(populatedObj)).toEqual(populatedObj); 22 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Rob Kendal 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /src/assets/_buttons.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: $light-grey; 3 | border: 1px solid darken($color: $light-grey, $amount: 10%); 4 | margin: 0.5em 0.5em 0.5em 0; 5 | border-radius: 25px; 6 | padding: 0.3em 1em; 7 | cursor: pointer; 8 | 9 | &:hover { 10 | background-color: darken($color: $light-grey, $amount: 10%); 11 | } 12 | 13 | &.is-primary { 14 | background-color: $green; 15 | border: 1px solid darken($color: $green, $amount: 10%); 16 | color: #222; 17 | 18 | &:hover { 19 | background-color: darken($color: $green, $amount: 10%); 20 | } 21 | } 22 | 23 | &.is-danger { 24 | background-color: $red; 25 | border: 1px solid darken($color: $red, $amount: 10%); 26 | color: white; 27 | 28 | &:hover { 29 | background-color: darken($color: $red, $amount: 10%); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const merge = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | const path = require('path'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | entry: { 9 | example: './example/src/index.js' 10 | }, 11 | output: { 12 | filename: '[name].bundle.min.js', 13 | path: path.resolve(__dirname, "../dist/example") 14 | }, 15 | devtool: 'inline-source-map', 16 | devServer: { 17 | compress: false, 18 | port: 8080, 19 | open: true, 20 | watchOptions: { 21 | poll: true 22 | }, 23 | watchContentBase: true 24 | }, 25 | plugins: [ 26 | new HtmlWebpackPlugin({ 27 | title: 'React Visual Query Builder - Example', 28 | inject: 'footer', 29 | template: './example/src/index.html' 30 | }), 31 | ] 32 | }); 33 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 6 | const path = require('path'); 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | entry: { 11 | index: './src/index.js' 12 | }, 13 | output: { 14 | filename: '[name].js', 15 | path: path.resolve(__dirname, "..", "dist"), 16 | libraryTarget: 'commonjs2' 17 | }, 18 | devtool: 'none', 19 | externals: { 20 | react: 'commonjs react', 21 | 'react-dom': 'commonjs react-dom', 22 | }, 23 | optimization: { 24 | minimizer: [ 25 | new UglifyJsPlugin({ 26 | cache: true, 27 | parallel: true, 28 | sourceMap: false // set to true if you want JS source maps 29 | }), 30 | new OptimizeCSSAssetsPlugin({}) 31 | ] 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/fields/Input.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | 5 | /** 6 | * @typedef {object} InputProps 7 | * @property {string} value 8 | * @property {function} onValueChange 9 | */ 10 | 11 | /** 12 | * @param {InputProps} props 13 | */ 14 | const Input = props => { 15 | 16 | const [selectedValue, setSelectedValue] = useState(props.value || ''); 17 | 18 | /** 19 | * When a user enters some input, update state, dispatch updated value 20 | * @param {event} evt - the synthetic React event 21 | */ 22 | const handleValueChange = evt => { 23 | setSelectedValue(evt.target.value); 24 | 25 | if (props.onValueChange) { 26 | props.onValueChange(evt.target.value); 27 | } 28 | } 29 | 30 | return ( 31 | 39 | ); 40 | }; 41 | 42 | Input.defaultProps = { 43 | value: '', 44 | onValueChange: null, 45 | }; 46 | 47 | Input.propTypes = { 48 | value: PropTypes.string, 49 | onValueChange: PropTypes.func, 50 | }; 51 | 52 | export default Input; -------------------------------------------------------------------------------- /src/constants/propTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | 4 | const field = PropTypes.shape( 5 | { 6 | label: PropTypes.string.isRequired, 7 | name: PropTypes.string.isRequired, 8 | dataType: PropTypes.string, 9 | type: PropTypes.string, 10 | operators: PropTypes.arrayOf(PropTypes.string), 11 | data: PropTypes.array 12 | } 13 | ); 14 | 15 | const value = PropTypes.oneOfType([ 16 | PropTypes.string, 17 | PropTypes.bool 18 | ]); 19 | 20 | const PROP_TYPES = { 21 | FIELD: field, 22 | FIELDS: PropTypes.arrayOf(field), 23 | RULE_GROUP: PropTypes.shape( 24 | { 25 | id: PropTypes.string, 26 | rules: PropTypes.arrayOf(PropTypes.shape( 27 | { 28 | id: PropTypes.string, 29 | field: PropTypes.string, 30 | value: value, 31 | operator: PropTypes.string 32 | } 33 | )), 34 | combinator: PropTypes.string, 35 | not: PropTypes.bool, 36 | } 37 | ), 38 | RULE: PropTypes.shape( 39 | { 40 | id: PropTypes.string, 41 | field: PropTypes.string, 42 | value: value, 43 | operator: PropTypes.string, 44 | } 45 | ), 46 | VALUE: value, 47 | }; 48 | 49 | export default PROP_TYPES; -------------------------------------------------------------------------------- /src/components/__tests__/FieldContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import FieldContainer from '../FieldContainer'; 5 | import FIELD_TYPES from '../../constants/fieldTypes'; 6 | 7 | 8 | const initialProps = Object.freeze({ 9 | field: { 10 | label: '', 11 | name: '', 12 | dataType: '', 13 | type: '', 14 | }, 15 | operator: '', 16 | value: 'my test value', 17 | onValueChange: jest.fn(), 18 | }); 19 | 20 | it('renders without crashing', () => { 21 | shallow(); 22 | }); 23 | 24 | describe('when rendering with props', () => { 25 | 26 | it('shows the correct value if set via props', () => { 27 | const wrapper = mount(); 28 | const element = wrapper.find('input[type="text"]'); 29 | 30 | expect(element).toExist(); 31 | expect(element).toHaveProp('value', initialProps.value); 32 | }); 33 | 34 | it('renders the correct field based on provided dataType', () => { 35 | const props = { 36 | ...initialProps, 37 | field: { 38 | ...initialProps.field, 39 | type: FIELD_TYPES.TOGGLE 40 | } 41 | } 42 | const wrapper = mount(); 43 | const element = wrapper.find('input[type="text"]'); 44 | const checkbox = wrapper.find('input[type="checkbox"]'); 45 | 46 | expect(element).not.toExist(); 47 | expect(checkbox).toExist(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/helpers/utils.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Check for empty object, string, or array and return true/false 4 | * @param {object} value 5 | * @returns {boolean} 6 | */ 7 | export const isNullOrEmpty = value => { 8 | if (typeof value === 'undefined' 9 | || (Array.isArray(value) && !value.length) 10 | || !value) { 11 | return true; 12 | } 13 | 14 | return false; 15 | }; 16 | 17 | /** 18 | * Recursively searches for a rule group or rule using array reducer and and returns it 19 | * @param {string} id - id of the rule/group to find 20 | * @param {object} parent - the containing rule/group object 21 | * @returns {object} - the found rule or null if not found 22 | */ 23 | export const findRuleOrGroup = (id, parent) => { 24 | if (parent.id === id) { 25 | return parent 26 | } 27 | 28 | return parent.rules && parent.rules.reduce((previousItem, item) => { 29 | if (item.id === id) { 30 | return item; 31 | } 32 | if (item.rules) { 33 | return findRuleOrGroup(id, item); 34 | } 35 | 36 | return previousItem; 37 | }, null); 38 | }; 39 | 40 | /** 41 | * For list-based field values, we join the input array via a pipe ('|') into a string. 42 | * Using this helper method, we split that field value into an array and return it, or an empty string. 43 | * @param {string} field 44 | * @returns {array} - can be an array or empty string if parameter is empty 45 | */ 46 | export const getFieldValue = field => { 47 | if (isNullOrEmpty(field)) { 48 | return ''; 49 | } 50 | 51 | return field.split('|'); 52 | }; -------------------------------------------------------------------------------- /src/components/fields/Toggle.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | import PROP_TYPES from '../../constants/propTypes'; 5 | 6 | /** 7 | * @typedef {object} ToggleProps 8 | * @property {string} value 9 | * @property {function} onValueChange 10 | */ 11 | 12 | /** 13 | * @param {ToggleProps} props 14 | */ 15 | const Toggle = props => { 16 | 17 | const inputId = nextId(); 18 | const [labelText, setLabelText] = useState('off'); 19 | const [selectedValue, setSelectedValue] = useState(props.value || false); 20 | 21 | /** 22 | * When a user changes the toggle, update state, dispatch updated value 23 | * @param {event} evt - the synthetic React event 24 | */ 25 | const handleValueChange = evt => { 26 | const isChecked = !selectedValue; 27 | setSelectedValue(isChecked); 28 | setLabelText(isChecked ? 'on' : 'off'); 29 | 30 | if (props.onValueChange) { 31 | props.onValueChange(isChecked); 32 | } 33 | } 34 | 35 | return ( 36 |
37 | 44 | 45 |
46 | 47 | ); 48 | }; 49 | 50 | Toggle.defaultProps = { 51 | value: false, 52 | onValueChange: null, 53 | }; 54 | 55 | Toggle.propTypes = { 56 | value: PROP_TYPES.VALUE, 57 | onValueChange: PropTypes.func, 58 | }; 59 | 60 | export default Toggle; -------------------------------------------------------------------------------- /src/components/fields/Slider.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | 5 | /** 6 | * @typedef {object} SliderProps 7 | * @property {{min: number; max: number;}} range 8 | * @property {string} value 9 | * @property {function} onValueChange 10 | */ 11 | 12 | /** 13 | * @param {SliderProps} props 14 | */ 15 | const Slider = props => { 16 | 17 | const inputId = nextId(); 18 | const [selectedValue, setSelectedValue] = useState(props.value || ''); 19 | 20 | /** 21 | * When a changes the slider value, update state, dispatch updated value 22 | * @param {event} evt - the synthetic React event 23 | */ 24 | const handleValueChange = evt => { 25 | setSelectedValue(evt.target.value); 26 | 27 | if (props.onValueChange) { 28 | props.onValueChange(evt.target.value); 29 | } 30 | } 31 | 32 | return ( 33 | <> 34 | 45 | {selectedValue} 46 | 47 | ); 48 | }; 49 | 50 | Slider.defaultProps = { 51 | range: { 52 | min: 0, 53 | max: 100 54 | }, 55 | value: '', 56 | onValueChange: null, 57 | }; 58 | 59 | Slider.propTypes = { 60 | range: PropTypes.shape({ 61 | min: PropTypes.number, 62 | max: PropTypes.number 63 | }), 64 | value: PropTypes.string, 65 | onValueChange: PropTypes.func, 66 | }; 67 | 68 | export default Slider; -------------------------------------------------------------------------------- /src/components/fields/InputNumber.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | 5 | /** 6 | * @typedef {object} InputNumberProps 7 | * @property {string} value 8 | * @property {function} onValueChange 9 | */ 10 | 11 | /** 12 | * @param {InputNumberProps} props 13 | */ 14 | const InputNumber = props => { 15 | 16 | const [selectedValue, setSelectedValue] = useState(props.value || ''); 17 | const [hasError, setHasError] = useState(false) 18 | 19 | /** 20 | * Checks if the input is numerical (good) or not (bad) 21 | * @param {string} value 22 | * @returns {boolean} 23 | */ 24 | const _hasValidationErrors = value => { 25 | const error = isNaN(parseFloat(value)); 26 | setHasError(error); 27 | return error; 28 | }; 29 | 30 | /** 31 | * When a user enters some input, check for errors, update state and dispatch updated value 32 | * @param {event} evt - the synthetic React event 33 | */ 34 | const handleValueChange = evt => { 35 | setSelectedValue(evt.target.value); 36 | 37 | if (_hasValidationErrors(evt.target.value)) { 38 | return; 39 | } 40 | 41 | if (props.onValueChange) { 42 | props.onValueChange(evt.target.value); 43 | } 44 | } 45 | 46 | return ( 47 | 55 | ); 56 | }; 57 | 58 | InputNumber.defaultProps = { 59 | value: '', 60 | onValueChange: null, 61 | }; 62 | 63 | InputNumber.propTypes = { 64 | value: PropTypes.string, 65 | onValueChange: PropTypes.func, 66 | }; 67 | 68 | export default InputNumber; -------------------------------------------------------------------------------- /src/components/__tests__/Slider.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import Slider from '../fields/Slider'; 5 | 6 | it('renders without crashing', () => { 7 | shallow(); 8 | }); 9 | 10 | describe('when rendering with props', () => { 11 | 12 | it('shows the correct value if set via props', () => { 13 | const props = { 14 | value: '50' 15 | }; 16 | 17 | const wrapper = mount(); 18 | const element = wrapper.find('input.slider'); 19 | 20 | expect(element).toExist(); 21 | expect(element).toHaveProp('value', props.value); 22 | }); 23 | }); 24 | 25 | describe('when handling events', () => { 26 | 27 | let wrapper, 28 | instance; 29 | 30 | const props = { 31 | value: '', 32 | onValueChange: jest.fn() 33 | }; 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | wrapper = mount(); 38 | instance = wrapper.instance(); 39 | }); 40 | 41 | it('doesn\'t call the props onValueChange event if not supplied', () => { 42 | const newProps = { 43 | value: '', 44 | }; 45 | wrapper = mount(); 46 | 47 | const element = wrapper.find('input.slider'); 48 | 49 | expect(element).toExist(); 50 | 51 | element.simulate('change'); 52 | 53 | expect(props.onValueChange).not.toHaveBeenCalled(); 54 | }); 55 | 56 | it('calls the props onValueChange event when input is changed', () => { 57 | const element = wrapper.find('input.slider'); 58 | const value = '123'; 59 | 60 | expect(element).toExist(); 61 | 62 | element.simulate('change', { target: { value: value } }); 63 | 64 | expect(props.onValueChange).toHaveBeenCalled(); 65 | expect(props.onValueChange).toHaveBeenCalledWith(value); 66 | }); 67 | }); -------------------------------------------------------------------------------- /src/components/__tests__/Input.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import Input from '../fields/Input'; 5 | 6 | it('renders without crashing', () => { 7 | shallow(); 8 | }); 9 | 10 | describe('when rendering with props', () => { 11 | 12 | it('shows the correct value if set via props', () => { 13 | const props = { 14 | value: 'this is a test' 15 | }; 16 | 17 | const wrapper = mount(); 18 | const element = wrapper.find('input.input'); 19 | 20 | expect(element).toExist(); 21 | expect(element).toHaveProp('value', props.value); 22 | }); 23 | }); 24 | 25 | describe('when handling events', () => { 26 | 27 | let wrapper, 28 | instance; 29 | 30 | const props = { 31 | value: '', 32 | onValueChange: jest.fn() 33 | }; 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | wrapper = mount(); 38 | instance = wrapper.instance(); 39 | }); 40 | 41 | it('doesn\'t call the props onValueChange event if not supplied', () => { 42 | const newProps = { 43 | value: '', 44 | }; 45 | wrapper = mount(); 46 | 47 | const element = wrapper.find('input.input'); 48 | 49 | expect(element).toExist(); 50 | 51 | element.simulate('change'); 52 | 53 | expect(props.onValueChange).not.toHaveBeenCalled(); 54 | }); 55 | 56 | it('calls the props onValueChange event when input is changed', () => { 57 | const element = wrapper.find('input.input'); 58 | const value = 'some test value'; 59 | 60 | expect(element).toExist(); 61 | 62 | element.simulate('change', { target: { value: value } }); 63 | 64 | expect(props.onValueChange).toHaveBeenCalled(); 65 | expect(props.onValueChange).toHaveBeenCalledWith(value); 66 | }); 67 | }); -------------------------------------------------------------------------------- /src/components/__tests__/Toggle.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import Toggle from '../fields/Toggle'; 5 | 6 | it('renders without crashing', () => { 7 | shallow(); 8 | }); 9 | 10 | describe('when rendering with props', () => { 11 | 12 | it('shows the correct value if set via props', () => { 13 | const props = { 14 | value: true 15 | }; 16 | 17 | const wrapper = mount(); 18 | const element = wrapper.find('input.switch'); 19 | 20 | expect(element).toExist(); 21 | expect(element).toHaveProp('checked', props.value); 22 | }); 23 | }); 24 | 25 | describe('when handling events', () => { 26 | 27 | let wrapper, 28 | instance; 29 | 30 | const props = { 31 | value: false, 32 | onValueChange: jest.fn() 33 | }; 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | wrapper = mount(); 38 | instance = wrapper.instance(); 39 | }); 40 | 41 | it('doesn\'t call the props onValueChange event if not supplied', () => { 42 | const newProps = { 43 | value: true, 44 | }; 45 | wrapper = mount(); 46 | 47 | const element = wrapper.find('input.switch'); 48 | 49 | expect(element).toExist(); 50 | 51 | element.simulate('change'); 52 | 53 | expect(props.onValueChange).not.toHaveBeenCalled(); 54 | }); 55 | 56 | it('calls the props onValueChange event when input is changed', () => { 57 | const element = wrapper.find('input.switch'); 58 | 59 | expect(element).toExist(); 60 | 61 | element.simulate('change'); 62 | 63 | expect(props.onValueChange).toHaveBeenCalled(); 64 | expect(props.onValueChange).toHaveBeenCalledWith(true); 65 | }); 66 | 67 | it('displays the correct label text', () => { 68 | const element = wrapper.find('label'); 69 | 70 | expect(element).toExist(); 71 | expect(element.text()).toContain('off'); 72 | }); 73 | }); -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Try out the React Visual Query Builder 26 | 28 | 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/__tests__/DropdownSelect.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import DropdownSelect from '../DropdownSelect'; 5 | 6 | it('renders without crashing', () => { 7 | shallow(); 8 | }); 9 | 10 | describe('when rendering with props', () => { 11 | 12 | it('shows the correct value if set via props', () => { 13 | const props = { 14 | selectedValue: 'this is a test', 15 | options: [ 16 | { text: 'one', value: 'value one' }, 17 | { text: 'two', value: 'value two' }, 18 | { text: 'three', value: 'value three' }, 19 | ] 20 | }; 21 | 22 | const wrapper = mount(); 23 | const element = wrapper.find('.select select'); 24 | 25 | expect(element).toExist(); 26 | expect(element).toHaveProp('value', props.selectedValue); 27 | }); 28 | }); 29 | 30 | describe('when handling events', () => { 31 | 32 | let wrapper, 33 | instance; 34 | 35 | const props = { 36 | selectedValue: '', 37 | onSelectChange: jest.fn(), 38 | options: [ 39 | { text: 'one', value: 'value one' }, 40 | { text: 'two', value: 'value two' }, 41 | { text: 'three', value: 'value three' }, 42 | ] 43 | }; 44 | 45 | beforeEach(() => { 46 | jest.clearAllMocks(); 47 | wrapper = mount(); 48 | instance = wrapper.instance(); 49 | }); 50 | 51 | it('doesn\'t call the props onValueChange event if not supplied', () => { 52 | const newProps = { 53 | ...props, 54 | onSelectChange: null 55 | }; 56 | wrapper = mount(); 57 | 58 | const element = wrapper.find('.select select'); 59 | 60 | expect(element).toExist(); 61 | 62 | element.simulate('change'); 63 | 64 | expect(props.onSelectChange).not.toHaveBeenCalled(); 65 | }); 66 | 67 | it('calls the props onValueChange event when input is changed', () => { 68 | const element = wrapper.find('.select select'); 69 | const value = '123'; 70 | 71 | expect(element).toExist(); 72 | 73 | element.simulate('change', { target: { value: value } }); 74 | 75 | expect(props.onSelectChange).toHaveBeenCalled(); 76 | expect(props.onSelectChange).toHaveBeenCalledWith(value); 77 | }); 78 | }); -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const path = require("path"); 4 | 5 | module.exports = { 6 | plugins: [ 7 | new CleanWebpackPlugin(), 8 | new MiniCssExtractPlugin({ 9 | filename: "[name].css", 10 | chunkFilename: "[id].css" 11 | }) 12 | ], 13 | resolve: { 14 | extensions: ['.js', '.jsx', '.scss'], 15 | alias: { 16 | fontawesome: path.resolve(__dirname, '../node_modules/@fortawesome/fontawesome-free/') 17 | } 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(js|jsx)$/, 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'babel-loader', 26 | options: { 27 | cacheDirectory: true, 28 | presets: [ 29 | "@babel/preset-env", 30 | "@babel/preset-react", 31 | { 32 | plugins: ["@babel/plugin-proposal-class-properties"] 33 | } 34 | ], 35 | } 36 | } 37 | }, 38 | { 39 | test: /\.(css|scss|sass)$/, 40 | use: [ 41 | //MiniCssExtractPlugin.loader, 42 | "style-loader", 43 | "css-loader", 44 | "sass-loader" 45 | ] 46 | }, 47 | { 48 | test: /\.(gif|png|jp(e*)g|svg)$/, 49 | use: [ 50 | 'file-loader', 51 | { 52 | loader: 'image-webpack-loader', 53 | options: { 54 | //disable: true, // webpack@2.x and newer 55 | }, 56 | }] 57 | }, 58 | // { 59 | // test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, 60 | // use: [{ 61 | // loader: 'file-loader', 62 | // options: { 63 | // name: '[name].[ext]', 64 | // outputPath: './dist/webfonts', 65 | // publicPath: './dist/webfonts' 66 | // } 67 | // }] 68 | // } 69 | ] 70 | } 71 | }; -------------------------------------------------------------------------------- /src/components/DropdownSelect.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | 5 | /** 6 | * @typedef {object} DropdownSelectProps 7 | * @property {array} options 8 | * @property {function} onSelectChange 9 | * @property {string} selectedValue 10 | */ 11 | 12 | /** 13 | * @param {DropdownSelectProps} props 14 | */ 15 | const DropdownSelect = props => { 16 | 17 | const defaultValue = "__default"; 18 | const [selectValue, setSelectValue] = useState(props.selectedValue || defaultValue); 19 | const { onSelectChange } = props; 20 | 21 | /** 22 | * When a user changes the select value, check if it's not the default option 23 | * update state, dispatch updated value 24 | * @param {event} evt - the synthetic React event 25 | */ 26 | const handleSelectChange = evt => { 27 | if (evt.target.value === defaultValue) { 28 | return; 29 | } 30 | 31 | setSelectValue(evt.target.value); 32 | 33 | if (onSelectChange) { 34 | onSelectChange(evt.target.value) 35 | } 36 | } 37 | 38 | return ( 39 |
40 | 58 |
59 | ); 60 | }; 61 | 62 | DropdownSelect.defaultProps = { 63 | options: [], 64 | onSelectChange: null, 65 | selectedValue: null, 66 | }; 67 | 68 | DropdownSelect.propTypes = { 69 | options: PropTypes.oneOfType( 70 | [ 71 | PropTypes.arrayOf(PropTypes.shape( 72 | { 73 | text: PropTypes.string, 74 | value: PropTypes.string 75 | } 76 | )), 77 | PropTypes.arrayOf(PropTypes.string) 78 | ] 79 | ), 80 | onSelectChange: PropTypes.func, 81 | selectedValue: PropTypes.string, 82 | }; 83 | 84 | export default DropdownSelect; -------------------------------------------------------------------------------- /src/constants/operators.js: -------------------------------------------------------------------------------- 1 | import DATA_TYPES from './dataTypes'; 2 | 3 | const OPERATORS = { 4 | IN: 'in', 5 | NOT_IN: 'not in', 6 | IS: 'is', 7 | IS_NOT: 'is not', 8 | IS_NULL: 'null', 9 | IS_NOT_NULL: 'is not null', 10 | EQUALS: '=', 11 | NOT_EQUALS: '!=', 12 | LESS_THAN: '<', 13 | LESS_THAN_OR_EQUAL: '<=', 14 | GREATER_THAN: '>', 15 | GREATER_THAN_OR_EQUAL: '>=', 16 | CONTAINS: 'contains', 17 | BEGINS_WITH: 'begins with', 18 | ENDS_WITH: 'ends with', 19 | DOES_NOT_CONTAIN: 'does not contain', 20 | DOES_NOT_BEGIN_WITH: 'does not begin with', 21 | DOES_NOT_END_WITH: 'does not end with', 22 | LIKE: 'like', 23 | NOT_LIKE: 'not like', 24 | BEFORE: 'before', 25 | AFTER: 'after', 26 | ON: 'on', 27 | IS_PRESENT: 'is present', 28 | IS_NOT_PRESENT: 'is not present', 29 | DATE_TIME: 'date time', 30 | BETWEEN: 'between', 31 | PLUS_MINUS_DAYS: 'plus / minus days', 32 | }; 33 | 34 | export const DATA_TYPE_OPERATORS = { 35 | DEFAULT: [ 36 | OPERATORS.EQUALS, 37 | OPERATORS.LESS_THAN, 38 | OPERATORS.LESS_THAN_OR_EQUAL, 39 | OPERATORS.GREATER_THAN, 40 | OPERATORS.GREATER_THAN_OR_EQUAL, 41 | OPERATORS.EQUALS, 42 | OPERATORS.NOT_EQUALS, 43 | OPERATORS.CONTAINS, 44 | OPERATORS.DOES_NOT_CONTAIN, 45 | OPERATORS.BEGINS_WITH, 46 | OPERATORS.DOES_NOT_BEGIN_WITH, 47 | OPERATORS.ENDS_WITH, 48 | OPERATORS.DOES_NOT_END_WITH 49 | ], 50 | [DATA_TYPES.NUMBER]: [ 51 | OPERATORS.EQUALS, 52 | OPERATORS.LESS_THAN, 53 | OPERATORS.LESS_THAN_OR_EQUAL, 54 | OPERATORS.GREATER_THAN, 55 | OPERATORS.GREATER_THAN_OR_EQUAL 56 | ], 57 | [DATA_TYPES.STRING]: [ 58 | OPERATORS.EQUALS, 59 | OPERATORS.NOT_EQUALS, 60 | OPERATORS.CONTAINS, 61 | OPERATORS.DOES_NOT_CONTAIN, 62 | OPERATORS.BEGINS_WITH, 63 | OPERATORS.DOES_NOT_BEGIN_WITH, 64 | OPERATORS.ENDS_WITH, 65 | OPERATORS.DOES_NOT_END_WITH 66 | ], 67 | [DATA_TYPES.TRUE_FALSE]: [ 68 | OPERATORS.IS, 69 | OPERATORS.IS_NOT 70 | ], 71 | [DATA_TYPES.LIST]: [ 72 | OPERATORS.IN, 73 | OPERATORS.NOT_IN 74 | ], 75 | [DATA_TYPES.DATE_TIME]: [ 76 | OPERATORS.BEFORE, 77 | OPERATORS.AFTER, 78 | OPERATORS.ON, 79 | OPERATORS.IS_PRESENT, 80 | OPERATORS.IS_NOT_PRESENT, 81 | OPERATORS.DATE_TIME, 82 | OPERATORS.BETWEEN, 83 | OPERATORS.PLUS_MINUS_DAYS 84 | ] 85 | }; 86 | 87 | export default OPERATORS; 88 | 89 | -------------------------------------------------------------------------------- /src/components/FieldContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Components 5 | import Input from './fields/Input'; 6 | import InputNumber from './fields/InputNumber'; 7 | import AutoComplete from './fields/AutoComplete'; 8 | import DatePicker from './fields/DatePicker'; 9 | import Toggle from './fields/Toggle'; 10 | import Slider from './fields/Slider'; 11 | 12 | // Constants 13 | import FIELD_TYPES from '../constants/fieldTypes'; 14 | import PROP_TYPES from '../constants/propTypes'; 15 | import OPERATORS from '../constants/operators'; 16 | 17 | /** 18 | * @typedef {object} fieldObj 19 | * @property {string} label 20 | * @property {string} name 21 | * @property {string} dataType 22 | * @property {string} type 23 | */ 24 | /** 25 | * @typedef {object} InputProps 26 | * @property {fieldObj} field 27 | * @property {string} value 28 | * @property {string} operator 29 | * @property {function} onValueChange 30 | */ 31 | 32 | /** 33 | * @param {InputProps} props 34 | */ 35 | const FieldContainer = props => { 36 | 37 | const { 38 | field, 39 | operator, 40 | value, 41 | onValueChange 42 | } = props; 43 | 44 | const type = field.type; 45 | 46 | const fieldProps = { 47 | operator, 48 | value, 49 | onValueChange, 50 | ...field 51 | }; 52 | 53 | // check for nullable type operators and return empty rather than display an input component 54 | if (operator && 55 | [ 56 | OPERATORS.IS_NOT_PRESENT, 57 | OPERATORS.IS_PRESENT, 58 | OPERATORS.NULL, 59 | OPERATORS.IS_NOT_NULL 60 | ].includes(operator)) { 61 | return <>; 62 | } 63 | 64 | switch (type) { 65 | case FIELD_TYPES.DATE: 66 | return ; 67 | case FIELD_TYPES.AUTOCOMPLETE_LIST: 68 | return ; 69 | case FIELD_TYPES.TOGGLE: 70 | return 71 | case FIELD_TYPES.NUMBER: 72 | return ; 73 | case FIELD_TYPES.SLIDER: 74 | return 75 | default: 76 | return ; 77 | } 78 | }; 79 | 80 | FieldContainer.defaultProps = { 81 | field: { 82 | label: '', 83 | name: '', 84 | dataType: '', 85 | type: '', 86 | }, 87 | operator: '', 88 | value: '', 89 | onValueChange: null, 90 | }; 91 | 92 | FieldContainer.propTypes = { 93 | field: PROP_TYPES.FIELD, 94 | operator: PropTypes.string, 95 | value: PROP_TYPES.VALUE, 96 | onValueChange: PropTypes.func.isRequired, 97 | }; 98 | 99 | export default FieldContainer; -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Constants 4 | import { simpleFields } from './constants/qbFields'; 5 | 6 | // Components 7 | import QBuilder from '../../src/index'; 8 | 9 | // Initial, test query to supply to the component 10 | const testQuery = { 11 | "id": "q-eYzqvRpGZWXoJXTSZQAtF", 12 | "rules": [ 13 | { 14 | "id": "g-x2P0kgQPeepc9H_KF2VmA", 15 | "rules": [ 16 | { 17 | "id": "r-zVFiaM-ofW-yRzmWzrWAG", 18 | "field": "firstName", 19 | "value": "something else", 20 | "operator": "ends with" 21 | }, 22 | { 23 | "id": "r-2_ST2tJ0P0udMZh-_ioDG", 24 | "field": "firstName", 25 | "value": "Spongebob", 26 | "operator": "contains" 27 | }, 28 | { 29 | "id": "g-zaS9TnPgMsXHVamt_W8Gr", 30 | "rules": [ 31 | { 32 | "id": "r-PgSGthQCroLv8XUqV6h7u", 33 | "field": "users", 34 | "value": "123", 35 | "operator": "is" 36 | }, 37 | { 38 | "id": "r-8q5KMtMNWxtTZv3S6lH5e", 39 | "field": "firstName", 40 | "value": "Alex Banana", 41 | "operator": "=" 42 | } 43 | ], 44 | "combinator": "OR", 45 | "not": false 46 | } 47 | ], 48 | "combinator": "AND", 49 | "not": false 50 | } 51 | ] 52 | }; 53 | 54 | 55 | class App extends React.Component { 56 | 57 | state = { 58 | simpleQuery: '', 59 | initialQuery: null, 60 | }; 61 | 62 | onSimpleQueryChange = queryJson => { 63 | this.setState({ 64 | simpleQuery: JSON.stringify(queryJson, null, 4) 65 | }); 66 | }; 67 | 68 | handleQueryLoadClick = () => { 69 | this.setState({ 70 | initialQuery: testQuery 71 | }); 72 | }; 73 | 74 | handleResetClick = () => { 75 | this.setState({ 76 | initialQuery: null 77 | }); 78 | }; 79 | 80 | render() { 81 | return ( 82 |
83 |

React Query Builder demo

84 |

Have a play about and see the query built and updated in real time below...

85 |

86 | 87 | 88 |

89 |
90 | 97 |
98 |
99 | 100 |
{this.state.simpleQuery}
101 |
102 |
103 |
104 | ); 105 | } 106 | }; 107 | 108 | export default App; -------------------------------------------------------------------------------- /src/assets/_fields.scss: -------------------------------------------------------------------------------- 1 | // Autocomplete 2 | .autocomplete-container { 3 | position: relative; 4 | 5 | input.input-search { 6 | height: 0; 7 | visibility: hidden; 8 | 9 | &.is-active { 10 | height: initial; 11 | visibility: visible; 12 | } 13 | } 14 | 15 | .autocomplete-results { 16 | position: absolute; 17 | left: -9999px; 18 | top: 0%; 19 | opacity: 0; 20 | z-index: 100; 21 | 22 | &.is-active { 23 | opacity: 1; 24 | left: 0; 25 | } 26 | 27 | ul { 28 | list-style: none; 29 | padding: 0; 30 | margin: 0; 31 | background-color: #fff; 32 | border: 1px solid $light-grey; 33 | width: 125%; 34 | overflow-y: scroll; 35 | font-size: 1em; 36 | border-radius: 0 0 5px 5px; 37 | max-height: 200px; 38 | } 39 | 40 | li { 41 | display: block; 42 | line-height: 1.7; 43 | padding: 0 0.5em; 44 | cursor: pointer; 45 | 46 | &:hover { 47 | background-color: darken($light-blue, 10%); 48 | outline: 1px solid darken($light-blue, 20%); 49 | } 50 | } 51 | } 52 | } 53 | 54 | // Basic inputs 55 | input { 56 | 57 | &.has-error { 58 | outline: 1px solid $red; 59 | } 60 | } 61 | 62 | // Toggle 63 | .switch[type='checkbox'] { 64 | 65 | outline: 0; 66 | user-select: none; 67 | display: inline-block; 68 | position: absolute; 69 | opacity: 0; 70 | 71 | &+ label { 72 | position: relative; 73 | display: block; 74 | font-size: 1rem; 75 | line-height: initial; 76 | padding-left: 2.5rem; 77 | padding-top: 0; 78 | cursor: pointer; 79 | 80 | &::before { 81 | background-color: $light-grey; 82 | border-radius: 25px; 83 | position: absolute; 84 | display: block; 85 | top: 0; 86 | left: 0; 87 | width: 2rem; 88 | height: 1rem; 89 | border: 0.1rem solid 90 | transparent; 91 | content: ''; 92 | } 93 | 94 | &::after { 95 | border-radius: 50%; 96 | display: block; 97 | position: absolute; 98 | top: .21rem; 99 | left: .25rem; 100 | width: .7rem; 101 | height: .7rem; 102 | transform: translate3d(0, 0, 0); 103 | background: #fff; 104 | transition: all 0.15s ease-out; 105 | content: ''; 106 | } 107 | } 108 | 109 | &:checked { 110 | &+ label { 111 | 112 | &::before { 113 | background-color: $green; 114 | } 115 | 116 | &::after { 117 | left: 1.25rem; 118 | } 119 | } 120 | } 121 | } 122 | 123 | // Slider 124 | @import './slider'; -------------------------------------------------------------------------------- /example/src/constants/qbFields.js: -------------------------------------------------------------------------------- 1 | import { FIELD_TYPES, OPERATORS, COMBINATORS, DATA_TYPES } from '../../../src/index'; 2 | 3 | export const simpleFields = { 4 | combinators: [ 5 | COMBINATORS.AND, 6 | COMBINATORS.OR 7 | ], 8 | fields: [ 9 | { 10 | label: 'Users', 11 | name: 'users', 12 | dataType: DATA_TYPES.STRING, 13 | type: FIELD_TYPES.AUTOCOMPLETE_LIST, 14 | operators: [ 15 | OPERATORS.IS, 16 | OPERATORS.IS_NOT 17 | ], 18 | data: [ 19 | { 20 | name: 'Abraham Lincoln', 21 | value: 123 22 | }, 23 | { 24 | name: 'Jonny Johnson', 25 | value: 345 26 | }, 27 | { 28 | name: 'Mr Black', 29 | value: 99 30 | }, 31 | { 32 | name: 'Davide Testington', 33 | value: 352 34 | }, 35 | { 36 | name: 'Alexis Smith', 37 | value: 3 38 | }, 39 | { 40 | name: 'Holly Willohby', 41 | value: 69 42 | }, 43 | { 44 | name: 'Walter White', 45 | value: 991 46 | }, 47 | { 48 | name: 'Walt Whitman', 49 | value: 993 50 | }, 51 | { 52 | name: 'Waltz A Doltz', 53 | value: 992 54 | }, 55 | { 56 | name: 'Frank Castle', 57 | value: 322 58 | }, 59 | { 60 | name: 'Jamie Lee Curtis', 61 | value: 781 62 | } 63 | ] 64 | }, 65 | { 66 | label: 'First Name', 67 | name: 'firstName', 68 | dataType: DATA_TYPES.STRING, 69 | type: FIELD_TYPES.TEXT, 70 | }, 71 | { 72 | label: 'Contract Date', 73 | name: 'contractDate', 74 | dataType: DATA_TYPES.DATE_TIME, 75 | type: FIELD_TYPES.DATE, 76 | }, 77 | { 78 | label: 'Number of users', 79 | name: 'numUsers', 80 | dataType: DATA_TYPES.NUMBER, 81 | type: FIELD_TYPES.NUMBER, 82 | }, 83 | { 84 | label: 'User activation status', 85 | name: 'useractive', 86 | dataType: DATA_TYPES.TRUE_FALSE, 87 | type: FIELD_TYPES.TOGGLE, 88 | }, 89 | { 90 | label: 'Number of licences', 91 | name: 'numLicences', 92 | dataType: DATA_TYPES.NUMBER, 93 | type: FIELD_TYPES.SLIDER, 94 | range: { 95 | min: 0, 96 | max: 100 97 | } 98 | } 99 | ] 100 | }; -------------------------------------------------------------------------------- /src/components/__tests__/InputNumber.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import { render, fireEvent, cleanup } from '@testing-library/react'; 5 | 6 | import InputNumber from '../fields/InputNumber'; 7 | 8 | it('renders without crashing', () => { 9 | shallow(); 10 | }); 11 | 12 | describe('when rendering with props', () => { 13 | 14 | it('shows the correct value if set via props', () => { 15 | const props = { 16 | value: 'this is a test' 17 | }; 18 | 19 | const wrapper = mount(); 20 | const element = wrapper.find('input.input'); 21 | 22 | expect(element).toExist(); 23 | expect(element).toHaveProp('value', props.value); 24 | }); 25 | }); 26 | 27 | describe('when handling events', () => { 28 | 29 | let wrapper, 30 | instance; 31 | 32 | const props = { 33 | value: '', 34 | onValueChange: jest.fn() 35 | }; 36 | 37 | beforeEach(() => { 38 | jest.clearAllMocks(); 39 | wrapper = mount(); 40 | instance = wrapper.instance(); 41 | }); 42 | 43 | it('doesn\'t call the props onValueChange event if not supplied', () => { 44 | const newProps = { 45 | value: '', 46 | }; 47 | wrapper = mount(); 48 | 49 | const element = wrapper.find('input.input'); 50 | 51 | expect(element).toExist(); 52 | 53 | element.simulate('change'); 54 | 55 | expect(props.onValueChange).not.toHaveBeenCalled(); 56 | }); 57 | 58 | it('calls the props onValueChange event when input is changed', () => { 59 | const element = wrapper.find('input.input'); 60 | const value = '123'; 61 | 62 | expect(element).toExist(); 63 | 64 | element.simulate('change', { target: { value: value } }); 65 | 66 | expect(props.onValueChange).toHaveBeenCalled(); 67 | expect(props.onValueChange).toHaveBeenCalledWith(value); 68 | }); 69 | }); 70 | 71 | describe('when handling errors', () => { 72 | 73 | let wrapper, 74 | instance; 75 | 76 | const props = { 77 | value: '', 78 | onValueChange: jest.fn() 79 | }; 80 | 81 | beforeEach(() => { 82 | jest.clearAllMocks(); 83 | wrapper = mount(); 84 | instance = wrapper.instance(); 85 | }); 86 | 87 | it('doesn\'t call the props onValueChange when there is an error', () => { 88 | const element = wrapper.find('input.input'); 89 | 90 | expect(element).toExist(); 91 | 92 | element.simulate('change', { target: { value: 'abcnotanumber' } }); 93 | 94 | expect(props.onValueChange).not.toHaveBeenCalled(); 95 | }); 96 | 97 | afterEach(cleanup); 98 | 99 | it('sets an error CSS class when there is an error', () => { 100 | const newProps = { 101 | value: '123' 102 | }; 103 | const { getByDisplayValue } = render(); 104 | const element = getByDisplayValue(newProps.value); 105 | 106 | expect(element).not.toHaveClass('has-error'); 107 | 108 | fireEvent.change(element, { target: { value: 'abcnotanumber' } }); 109 | 110 | expect(element).toHaveClass('has-error'); 111 | }); 112 | }); -------------------------------------------------------------------------------- /src/helpers/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isNullOrEmpty, 3 | findRuleOrGroup, 4 | getFieldValue 5 | } from '../utils'; 6 | 7 | 8 | describe('when testing null or empty check utilities', () => { 9 | 10 | it('returns true when value is null or empty', () => { 11 | const test1 = isNullOrEmpty(); 12 | const test2 = isNullOrEmpty([]); 13 | const test3 = isNullOrEmpty(''); 14 | const test4 = isNullOrEmpty(null); 15 | 16 | expect(test1).toBeTruthy(); 17 | expect(test2).toBeTruthy(); 18 | expect(test3).toBeTruthy(); 19 | expect(test4).toBeTruthy(); 20 | }); 21 | 22 | it('returns false when value is not null or empty', () => { 23 | const test1 = isNullOrEmpty('hello'); 24 | const test2 = isNullOrEmpty([0, 2, 1]); 25 | const test3 = isNullOrEmpty(true); 26 | const test4 = isNullOrEmpty({ greeting: 'hello' }); 27 | 28 | expect(test1).toBeFalsy(); 29 | expect(test2).toBeFalsy(); 30 | expect(test3).toBeFalsy(); 31 | expect(test4).toBeFalsy(); 32 | }); 33 | }); 34 | 35 | describe('when testing rule group finding', () => { 36 | 37 | it('correctly discovers and returns the right rule or group', () => { 38 | const needle = { 39 | id: '123', 40 | rules: [] 41 | }; 42 | const haystack = { 43 | id: '567', 44 | rules: [ 45 | { 46 | id: '098', 47 | }, 48 | { 49 | id: '198', 50 | }, 51 | { 52 | id: '298', 53 | }, 54 | { 55 | id: '398', 56 | rules: [ 57 | { 58 | id: '598', 59 | }, 60 | { 61 | id: '698', 62 | }, 63 | { 64 | id: '798', 65 | }, 66 | { 67 | id: '898', 68 | rules: [ 69 | { 70 | id: '998', 71 | }, 72 | { 73 | id: '1098', 74 | }, 75 | { 76 | ...needle 77 | } 78 | ] 79 | } 80 | ] 81 | }, 82 | { 83 | id: '498', 84 | } 85 | ] 86 | }; 87 | 88 | const match = findRuleOrGroup(needle.id, haystack); 89 | 90 | expect(match).not.toBeNull(); 91 | expect(match).toEqual(needle); 92 | }); 93 | }); 94 | 95 | describe('when testing field value returning', () => { 96 | 97 | it('returns an empty string if the field is empty', () => { 98 | expect(getFieldValue()).toBe(''); 99 | }); 100 | 101 | it('returns an array when the value is a joined string', () => { 102 | const field = 'value one|value two|third value|fourth'; 103 | expect(getFieldValue(field).length).toBe(4); 104 | }); 105 | }); -------------------------------------------------------------------------------- /src/components/__tests__/AutoComplete.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import { render, fireEvent, cleanup } from '@testing-library/react'; 5 | 6 | import AutoComplete from '../fields/AutoComplete'; 7 | 8 | it('renders without crashing', () => { 9 | shallow(); 10 | }); 11 | 12 | describe('when rendering with props', () => { 13 | 14 | it('shows the correct value if set via props', () => { 15 | const props = { 16 | value: 'one', 17 | data: [ 18 | { name: 'one', value: 'one' }, 19 | { name: 'two', value: 'two' }, 20 | { name: 'three', value: 'three' }, 21 | ] 22 | }; 23 | 24 | const wrapper = mount(); 25 | const element = wrapper.find('input.input-search'); 26 | const listItems = wrapper.find('li'); 27 | 28 | expect(element).toExist(); 29 | expect(element).toHaveProp('value', props.value); 30 | expect(listItems.length).toBe(props.data.length); 31 | }); 32 | }); 33 | 34 | describe('when handling events', () => { 35 | 36 | let wrapper, 37 | instance; 38 | 39 | const props = { 40 | value: '', 41 | onValueChange: jest.fn(), 42 | data: [ 43 | { name: 'one', value: 'one' }, 44 | { name: 'two', value: 'two' }, 45 | { name: 'three', value: 'three' }, 46 | ] 47 | }; 48 | 49 | beforeEach(() => { 50 | jest.clearAllMocks(); 51 | wrapper = mount(); 52 | instance = wrapper.instance(); 53 | }); 54 | 55 | it('doesn\'t call the props onValueChange event if not supplied', () => { 56 | const newProps = { 57 | ...props, 58 | onValueChange: null 59 | }; 60 | wrapper = mount(); 61 | 62 | const element = wrapper.find('li').first(); 63 | 64 | expect(element).toExist(); 65 | 66 | element.simulate('click'); 67 | 68 | expect(props.onValueChange).not.toHaveBeenCalled(); 69 | }); 70 | 71 | it('calls the props onValueChange event when input is changed', () => { 72 | const element = wrapper.find('li').first(); 73 | 74 | expect(element).toExist(); 75 | 76 | element.simulate('click'); 77 | 78 | expect(props.onValueChange).toHaveBeenCalled(); 79 | }); 80 | }); 81 | 82 | describe('when handling errors', () => { 83 | 84 | let wrapper, 85 | instance; 86 | 87 | const props = { 88 | value: 'onebigvalue', 89 | onValueChange: jest.fn() 90 | }; 91 | 92 | beforeEach(() => { 93 | jest.clearAllMocks(); 94 | wrapper = mount(); 95 | instance = wrapper.instance(); 96 | }); 97 | 98 | afterEach(cleanup); 99 | 100 | it('shows the correct inputs when searching occurs', () => { 101 | const { getByTestId } = render(); 102 | const element = getByTestId('inputAutoCompleteOne'); 103 | const results = getByTestId('divAutoCompleteResults'); 104 | 105 | expect(element).toHaveClass('is-active'); 106 | expect(results).not.toHaveClass('is-active'); 107 | 108 | fireEvent.focus(element); 109 | 110 | expect(element).not.toHaveClass('is-active'); 111 | expect(results).toHaveClass('is-active'); 112 | }); 113 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-visual-query-builder", 3 | "version": "1.1.0", 4 | "description": "A slightly opinionated query builder component built for React", 5 | "repository": "https://github.com/bpk68/react-visual-query-builder", 6 | "keywords": [ 7 | "react", 8 | "querybuilder", 9 | "query", 10 | "builder", 11 | "operators", 12 | "ui", 13 | "component", 14 | "expression" 15 | ], 16 | "main": "dist/index.js", 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "jest": { 21 | "setupFilesAfterEnv": [ 22 | "/setupTests.js" 23 | ], 24 | "verbose": true, 25 | "moduleNameMapper": { 26 | "\\.(css|scss|sass)$": "/__mocks__/styleMock.js" 27 | } 28 | }, 29 | "author": "Rob Kendal", 30 | "license": "ISC", 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "peerDependencies": { 44 | "react": ">=16.8.0 || 17.0.2", 45 | "react-dom": ">=16.8.0 || 17.0.2" 46 | }, 47 | "devDependencies": { 48 | "@babel/cli": "7.23.9", 49 | "@babel/core": "7.24.0", 50 | "@babel/plugin-proposal-class-properties": "7.18.6", 51 | "@babel/preset-env": "7.24.0", 52 | "@babel/preset-react": "7.23.3", 53 | "@testing-library/jest-dom": "6.4.2", 54 | "@testing-library/react": "14.2.1", 55 | "@typescript-eslint/eslint-plugin": "5.62.0", 56 | "@typescript-eslint/parser": "5.62.0", 57 | "@babel/eslint-parser": "7.23.10", 58 | "babel-jest": "27.5.1", 59 | "babel-loader": "9.1.3", 60 | "clean-webpack-plugin": "4.0.0", 61 | "css-loader": "6.11.0", 62 | "copy-webpack-plugin": "12.0.2", 63 | "enzyme": "3.11.0", 64 | "enzyme-adapter-react-16": "1.15.8", 65 | "eslint": "8.57.0", 66 | "eslint-config-react-app": "7.0.1", 67 | "eslint-plugin-flowtype": "8.0.3", 68 | "eslint-plugin-import": "2.29.1", 69 | "eslint-plugin-react": "7.34.1", 70 | "eslint-plugin-jsx-a11y": "6.8.0", 71 | "eslint-plugin-react-hooks": "4.6.2", 72 | "file-loader": "5.1.0", 73 | "html-webpack-plugin": "5.6.0", 74 | "image-webpack-loader": "8.1.0", 75 | "jest": "27.5.1", 76 | "jest-enzyme": "7.1.2", 77 | "node-sass": "9.0.0", 78 | "optimize-css-assets-webpack-plugin": "6.0.1", 79 | "mini-css-extract-plugin": "2.9.0", 80 | "react": "17.0.2", 81 | "react-dom": "17.0.2", 82 | "sass-loader": "12.6.0", 83 | "react-id-generator": "3.0.2", 84 | "react-test-renderer": "17.0.2", 85 | "style-loader": "3.3.4", 86 | "uglifyjs-webpack-plugin": "2.2.0", 87 | "url-loader": "4.1.1", 88 | "webpack": "5.91.0", 89 | "webpack-cli": "4.10.0", 90 | "webpack-dev-server": "4.15.1", 91 | "webpack-merge": "5.10.0" 92 | }, 93 | "dependencies": { 94 | "@fortawesome/fontawesome-free": "^6.0.0", 95 | "lodash": "^4.17.15", 96 | "moment": "^2.25.3", 97 | "nanoid": "^5.0.0", 98 | "prop-types": "^15.7.2", 99 | "react-datepicker": "^6.0.0" 100 | }, 101 | "scripts": { 102 | "pre": "npm run lint && npm run test && npm run build", 103 | "test": "jest", 104 | "lint": "eslint src/**/*.js src/**/*.jsx", 105 | "build": "webpack --config config/webpack.prod.js", 106 | "build:dev": "webpack --config config/webpack.dev.js", 107 | "start": "webpack-dev-server --open --config config/webpack.dev.js", 108 | "release": "npm run pre && npm publish --access=public" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/assets/_slider.scss: -------------------------------------------------------------------------------- 1 | // Lifted and edited from this gist 2 | // https://gist.github.com/hallojoe/d9e09af213e8e57b024706ffb3d413b3 3 | 4 | $range-track-width: 15rem; 5 | $range-track-height: .7em; 6 | $range-track-background: $light-grey; 7 | $range-track-background-focus: $light-grey; 8 | $range-track-border: none; 9 | $range-track-border-radius: $radius; 10 | $range-thumb-width: 1.5em; 11 | $range-thumb-height: 1.5em; 12 | $range-thumb-background: $dark-grey; 13 | $range-thumb-border: none; 14 | $range-thumb-border-radius: 50%; 15 | // -(thumb height / 2) - (track height / 2) 16 | $range-webkit-slider-thumb-margin-top: 17 | calc((calc(-#{$range-thumb-width} / 2) - calc(-#{$range-track-height} / 2))); 18 | 19 | @mixin range-track { 20 | border: $range-track-border; 21 | width: $range-track-width; 22 | height: $range-track-height; 23 | background: $range-track-background; 24 | border-radius: $range-track-border-radius; 25 | } 26 | 27 | @mixin range-thumb { 28 | border: $range-thumb-border; 29 | width: $range-thumb-width; 30 | height: $range-thumb-height; 31 | border-radius: $range-thumb-border-radius; 32 | background: $range-thumb-background; 33 | border: 1px solid #fff; 34 | box-shadow: 0 0 0 0.2rem hsla(0, 0%, 46%, 0.5); 35 | cursor: pointer; 36 | &:hover, &:focus, &:active { 37 | background: hsla(0, 0%, 46%, 0.5); 38 | } 39 | 40 | } 41 | 42 | input[type=range].slider { 43 | 44 | -webkit-appearance: none; 45 | width: $range-track-width; 46 | 47 | &::-webkit-slider-runnable-track { 48 | @include range-track(); 49 | } 50 | 51 | &::-webkit-slider-thumb { 52 | -webkit-appearance: none; 53 | margin-top: $range-webkit-slider-thumb-margin-top; 54 | @include range-thumb(); 55 | } 56 | 57 | &:focus { 58 | outline: none; 59 | } 60 | 61 | &:focus::-webkit-slider-runnable-track { 62 | background: $range-track-background-focus; 63 | } 64 | 65 | // apply FF only rules 66 | @supports (-moz-appearance:none) { 67 | /* 68 | fix for FF unable to apply focus style bug 69 | note: this is fixed as of version 60: 70 | https://bugzilla.mozilla.org/show_bug.cgi?id=712130 71 | */ 72 | border: 1px solid transparent; 73 | } 74 | 75 | 76 | &::-moz-range-track { 77 | @include range-track(); 78 | } 79 | 80 | &::-moz-range-thumb { 81 | @include range-thumb(); 82 | } 83 | 84 | /* 85 | hide the outline behind the border 86 | */ 87 | &:-moz-focusring{ 88 | outline: 1px solid transparent; 89 | outline-offset: -1px; 90 | } 91 | 92 | &::-moz-focus-outer { 93 | border: 0; 94 | } 95 | 96 | &:focus::-moz-range-track { 97 | background: $range-track-background-focus; 98 | } 99 | 100 | 101 | &::-ms-track { 102 | width: $range-track-width; 103 | height: $range-track-height; 104 | 105 | /* 106 | remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead 107 | */ 108 | background: transparent; 109 | 110 | /* 111 | leave room for the larger thumb to overflow with a transparent border 112 | */ 113 | border-color: transparent; 114 | border-width: 20px 0; 115 | 116 | /* 117 | remove default tick marks 118 | */ 119 | color: transparent; 120 | } 121 | &::-ms-fill-lower { 122 | background: #777;//#777;//$range-track-background; 123 | border-radius: 10px; 124 | } 125 | &::-ms-fill-upper { 126 | background: #ddd;//#888;//$range-track-background; 127 | border-radius: 10px; 128 | } 129 | &::-ms-thumb { 130 | @include range-thumb(); 131 | // edge 132 | @supports (-ms-ime-align: auto) { 133 | & { 134 | margin-top:-4px; 135 | } 136 | } 137 | } 138 | &:focus::-ms-fill-lower { 139 | background: #888;//$range-track-background-focus; 140 | } 141 | &:focus::-ms-fill-upper { 142 | background: #ccc; //$range-track-background-focus; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/__tests__/DatePicker.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import { render, fireEvent, cleanup } from '@testing-library/react'; 5 | 6 | import DatePicker from '../fields/DatePicker'; 7 | import OPERATORS from '../../constants/operators'; 8 | 9 | it('renders without crashing', () => { 10 | shallow(); 11 | }); 12 | 13 | describe('when rendering with props', () => { 14 | 15 | it('shows the correct value if set via props', () => { 16 | const props = { 17 | value: '1574689492905' 18 | }; 19 | 20 | const wrapper = mount(); 21 | const element = wrapper.find('.react-datepicker__input-container input'); 22 | 23 | expect(element).toExist(); 24 | }); 25 | }); 26 | 27 | describe('when handling events', () => { 28 | 29 | let wrapper, 30 | instance; 31 | 32 | const props = { 33 | value: '1574689492905', 34 | onValueChange: jest.fn() 35 | }; 36 | 37 | beforeEach(() => { 38 | jest.clearAllMocks(); 39 | wrapper = mount(); 40 | instance = wrapper.instance(); 41 | }); 42 | 43 | it('only shows one date picker by default', () => { 44 | const element = wrapper.find('.react-datepicker__input-container input'); 45 | 46 | expect(element.length).toBe(1); 47 | }); 48 | 49 | it('shows two datepickers when the correct operator is set', () => { 50 | const newProps = { 51 | ...props, 52 | operator: OPERATORS.BETWEEN 53 | }; 54 | wrapper = mount(); 55 | const element = wrapper.find('.react-datepicker__input-container input'); 56 | 57 | expect(element.length).toBe(2); 58 | }); 59 | 60 | it('shows the days selector input when the correct operator is set', () => { 61 | const newProps = { 62 | ...props, 63 | operator: OPERATORS.PLUS_MINUS_DAYS 64 | }; 65 | wrapper = mount(); 66 | const element = wrapper.find('input.input'); 67 | 68 | expect(element).toExist(); 69 | }); 70 | 71 | it('doesn\'t call the props onValueChange event if not supplied', () => { 72 | const newProps = { 73 | value: '1574689492905', 74 | }; 75 | wrapper = mount(); 76 | 77 | const element = wrapper.find('.react-datepicker__input-container input'); 78 | 79 | expect(element).toExist(); 80 | 81 | element.simulate('change', { target: { value: '11/12/2019' } }); 82 | 83 | expect(props.onValueChange).not.toHaveBeenCalled(); 84 | }); 85 | 86 | it('calls the props onValueChange event when input is changed', () => { 87 | const element = wrapper.find('.react-datepicker__input-container input'); 88 | 89 | expect(element).toExist(); 90 | 91 | element.simulate('change', { target: { value: '11/12/2019' } }); 92 | 93 | expect(props.onValueChange).toHaveBeenCalled(); 94 | }); 95 | }); 96 | 97 | describe('when validating input', () => { 98 | 99 | let wrapper, 100 | instance; 101 | 102 | const props = { 103 | value: '1574689492905', 104 | operator: OPERATORS.BETWEEN, 105 | onValueChange: jest.fn() 106 | }; 107 | 108 | beforeEach(() => { 109 | jest.clearAllMocks(); 110 | wrapper = mount(); 111 | instance = wrapper.instance(); 112 | }); 113 | 114 | afterEach(cleanup); 115 | 116 | it('won\'t allow the end date to be set _before_ the start date', () => { 117 | const { getByPlaceholderText } = render(); 118 | const startDate = getByPlaceholderText('choose a start date'); 119 | const endDate = getByPlaceholderText('choose an end date'); 120 | 121 | fireEvent.change(endDate, { target: { value: '11/12/2019' } }); 122 | 123 | expect(endDate).toHaveValue('11/12/2019'); 124 | 125 | fireEvent.change(startDate, { target: { value: '01/03/2020' } }); 126 | 127 | expect(endDate).toHaveValue('01/03/2020'); 128 | }); 129 | }); -------------------------------------------------------------------------------- /src/components/__tests__/Rule.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import { render, fireEvent, cleanup } from '@testing-library/react'; 5 | 6 | import Rule from '../Rule'; 7 | import DATA_TYPES from '../../constants/dataTypes'; 8 | import FIELD_TYPES from '../../constants/fieldTypes'; 9 | import generateRule from '../../helpers/generateRule'; 10 | 11 | 12 | const initialProps = Object.freeze({ 13 | fields: [ 14 | { 15 | label: 'First Name', 16 | name: 'firstName', 17 | dataType: DATA_TYPES.STRING, 18 | type: FIELD_TYPES.TEXT, 19 | }, 20 | { 21 | label: 'Contract Date', 22 | name: 'contractDate', 23 | dataType: DATA_TYPES.DATE_TIME, 24 | type: FIELD_TYPES.DATE, 25 | }, 26 | { 27 | label: 'Number of users', 28 | name: 'numUsers', 29 | dataType: DATA_TYPES.NUMBER, 30 | type: FIELD_TYPES.NUMBER, 31 | operators: ['in', 'null', 'between'] 32 | }, 33 | { 34 | label: 'User activation status', 35 | name: 'useractive', 36 | dataType: DATA_TYPES.TRUE_FALSE, 37 | type: FIELD_TYPES.TOGGLE, 38 | }, 39 | ], 40 | rule: generateRule(), 41 | onRemoveRule: jest.fn(), 42 | onRuleChange: jest.fn() 43 | }); 44 | 45 | it('renders without crashing', () => { 46 | shallow(); 47 | }); 48 | 49 | describe('when rendering with props', () => { 50 | 51 | it('shows the correct value if set via props', () => { 52 | const wrapper = mount(); 53 | const elements = wrapper.find('select'); 54 | const btnDelete = wrapper.find('button.is-danger'); 55 | 56 | expect(elements).toExist(); 57 | expect(elements.length).toBe(2); 58 | expect(btnDelete).toExist(); 59 | // The +1 here accounts for the __default option being injected to the select list 60 | expect(elements.first().find('option').length).toBe(initialProps.fields.length + 1); 61 | }); 62 | }); 63 | 64 | describe('when handling events', () => { 65 | 66 | let wrapper; 67 | 68 | beforeEach(() => { 69 | jest.clearAllMocks(); 70 | wrapper = mount(); 71 | }); 72 | 73 | it('doesn\'t call the props onRuleChange event if not supplied', () => { 74 | const newProps = { 75 | ...initialProps, 76 | onRuleChange: null 77 | }; 78 | wrapper = mount(); 79 | 80 | const element = wrapper.find('button.is-danger'); 81 | 82 | expect(element).toExist(); 83 | 84 | element.simulate('click'); 85 | 86 | expect(initialProps.onRuleChange).toHaveBeenCalledTimes(0); 87 | }); 88 | 89 | 90 | it('handles the delete rule event and fires onRuleChange', () => { 91 | const element = wrapper.find('button.is-danger'); 92 | 93 | expect(element).toExist(); 94 | 95 | element.simulate('click'); 96 | 97 | expect(initialProps.onRemoveRule).toHaveBeenCalled(); 98 | expect(initialProps.onRemoveRule).toHaveBeenCalledWith(initialProps.rule.id); 99 | }); 100 | 101 | it('updates the operators select list when the field select is changed', () => { 102 | const elements = wrapper.find('select'); 103 | 104 | expect(elements).toExist(); 105 | expect(elements.length).toBe(2); 106 | expect(elements.last().find('option').length).toBe(1); 107 | 108 | elements.first().simulate('change', { target: { value: initialProps.fields[2].name } }); 109 | 110 | // Need to find the updated elements 111 | const updatedElements = wrapper.find('select'); 112 | 113 | // The +1 here accounts for the __default option being injected to the select list 114 | expect(updatedElements.last().find('option').length).toBe(initialProps.fields[2].operators.length + 1); 115 | expect(initialProps.onRuleChange).toHaveBeenCalled(); 116 | }); 117 | 118 | it('changes the field type when the field select is changed', () => { 119 | const elements = wrapper.find('select'); 120 | let currentField = wrapper.find('input[type="text"]'); 121 | let numberField = wrapper.find('input[type="number"]'); 122 | 123 | expect(elements).toExist(); 124 | expect(elements.length).toBe(2); 125 | expect(currentField).toExist(); 126 | expect(numberField).not.toExist(); 127 | 128 | elements.first().simulate('change', { target: { value: initialProps.fields[2].name } }); 129 | 130 | currentField = wrapper.find('input[type="text"]'); 131 | numberField = wrapper.find('input[type="number"]'); 132 | expect(currentField).not.toExist(); 133 | expect(numberField).toExist(); 134 | }); 135 | }); -------------------------------------------------------------------------------- /src/components/__tests__/RuleGroup.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import RuleGroup from '../RuleGroup'; 5 | import DATA_TYPES from '../../constants/dataTypes'; 6 | import FIELD_TYPES from '../../constants/fieldTypes'; 7 | import generateRuleGroup from '../../helpers/generateRuleGroup'; 8 | 9 | 10 | const initialProps = Object.freeze({ 11 | parentGroupId: 'g-abc-123-yui', 12 | fields: [ 13 | { 14 | label: 'First Name', 15 | name: 'firstName', 16 | dataType: DATA_TYPES.STRING, 17 | type: FIELD_TYPES.TEXT, 18 | }, 19 | { 20 | label: 'Contract Date', 21 | name: 'contractDate', 22 | dataType: DATA_TYPES.DATE_TIME, 23 | type: FIELD_TYPES.DATE, 24 | }, 25 | { 26 | label: 'Number of users', 27 | name: 'numUsers', 28 | dataType: DATA_TYPES.NUMBER, 29 | type: FIELD_TYPES.NUMBER, 30 | }, 31 | { 32 | label: 'User activation status', 33 | name: 'useractive', 34 | dataType: DATA_TYPES.TRUE_FALSE, 35 | type: FIELD_TYPES.TOGGLE, 36 | }, 37 | ], 38 | ruleGroup: generateRuleGroup(), 39 | combinators: ['and', 'or'], 40 | onRuleGroupChange: jest.fn(), 41 | onRemoveRuleGroup: jest.fn(), 42 | showRemoveButton: false, 43 | }); 44 | 45 | it('renders without crashing', () => { 46 | shallow(); 47 | }); 48 | 49 | describe('when rendering with props', () => { 50 | 51 | let wrapper; 52 | 53 | beforeEach(() => { 54 | jest.clearAllMocks(); 55 | wrapper = mount(); 56 | }); 57 | 58 | it('shows the correct value if set via props', () => { 59 | const elements = wrapper.find('button.is-primary'); 60 | const ruleGroup = wrapper.find('div#' + initialProps.ruleGroup.id + ''); 61 | 62 | expect(elements).toExist(); 63 | expect(elements.length).toBe(2); 64 | expect(ruleGroup).toExist(); 65 | }); 66 | 67 | it('does not show the remove group button if flag set to false', () => { 68 | const btnDelete = wrapper.find('button.is-danger'); 69 | 70 | expect(btnDelete).not.toExist(); 71 | }); 72 | }); 73 | 74 | describe('when handling events', () => { 75 | 76 | let wrapper; 77 | 78 | beforeEach(() => { 79 | jest.clearAllMocks(); 80 | initialProps.onRuleGroupChange.mockClear(); 81 | 82 | wrapper = mount(); 83 | }); 84 | 85 | it('doesn\'t call the props onRuleGroupChange event if not supplied', () => { 86 | initialProps.onRuleGroupChange.mockClear(); 87 | const newProps = { 88 | ...initialProps, 89 | onRuleGroupChange: null 90 | }; 91 | wrapper = mount(); 92 | 93 | const element = wrapper.find('button.is-primary').first(); 94 | 95 | expect(element).toExist(); 96 | 97 | element.simulate('click'); 98 | 99 | expect(initialProps.onRuleGroupChange).toHaveBeenCalledTimes(0); 100 | }); 101 | 102 | it('handles the combinator change event and fires onRuleGroupChange', () => { 103 | const element = wrapper.find('select').first(); 104 | const updatedGroup = { 105 | ...initialProps.ruleGroup, 106 | combinator: initialProps.combinators[0] 107 | }; 108 | 109 | expect(element).toExist(); 110 | 111 | element.simulate('change', { target: { value: initialProps.combinators[0] } }); 112 | 113 | expect(initialProps.onRuleGroupChange).toHaveBeenCalled(); 114 | expect(initialProps.onRuleGroupChange).toHaveBeenCalledWith(updatedGroup); 115 | }); 116 | 117 | it('handles the add rule event and fires onRuleGroupChange', () => { 118 | const element = wrapper.find('button.is-primary').first(); 119 | 120 | expect(element).toExist(); 121 | 122 | element.simulate('click'); 123 | 124 | expect(initialProps.onRuleGroupChange).toHaveBeenCalled(); 125 | expect(initialProps.onRuleGroupChange.mock.calls[1][0].rules.length).toBeGreaterThan(0); 126 | }); 127 | 128 | it('handles the add rule group event and fires onRuleGroupChange', () => { 129 | const element = wrapper.find('button.is-primary').last(); 130 | 131 | expect(element).toExist(); 132 | 133 | element.simulate('click'); 134 | 135 | expect(initialProps.onRuleGroupChange).toHaveBeenCalled(); 136 | expect(initialProps.onRuleGroupChange.mock.calls[1][0].rules[0].id).toContain('g-'); 137 | }); 138 | 139 | it('handles the delete rule group event and fires onRuleGroupChange', () => { 140 | const props = { 141 | ...initialProps, 142 | showRemoveButton: true 143 | }; 144 | wrapper = mount(); 145 | const element = wrapper.find('button.is-danger').last(); 146 | 147 | expect(element).toExist(); 148 | 149 | element.simulate('click'); 150 | 151 | expect(initialProps.onRemoveRuleGroup).toHaveBeenCalled(); 152 | }); 153 | }); -------------------------------------------------------------------------------- /src/QBuilder.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import cloneDeep from 'lodash/cloneDeep'; 3 | import PropTypes from 'prop-types'; 4 | 5 | // Styles 6 | import './assets/styles.scss'; 7 | import 'fontawesome/js/all.js'; 8 | 9 | // Components 10 | import RuleGroup from './components/RuleGroup'; 11 | 12 | // Helpers 13 | import generateSimpleQuery from './helpers/generateSimpleQuery'; 14 | import { findRuleOrGroup } from './helpers/utils'; 15 | 16 | /** 17 | * @typedef {object} QBuilderProps 18 | * @property {array} targets 19 | * @property {array} fields 20 | * @property {array} combinators 21 | * @property {function} onQueryChange 22 | * @property {boolean} useCustomStyles 23 | * @property {object} query 24 | */ 25 | 26 | /** 27 | * @param {QBuilderProps} props 28 | */ 29 | const QBuilder = props => { 30 | 31 | /** 32 | * Checks for a passed in query object and returns a populated query object or empty query object 33 | * @returns {object} - query object 34 | */ 35 | const _getInitialQuery = useCallback(() => { 36 | return (props.query && generateSimpleQuery(props.query)) || generateSimpleQuery(); 37 | }, [props.query]); 38 | 39 | 40 | const { combinators, fields } = props; 41 | const [query, setQuery] = useState(_getInitialQuery()); 42 | 43 | 44 | /** 45 | * Given an updated query object, we do a deep clone from lodash library and pass it along 46 | * to the parent component's onQueryChange method 47 | * @param {object} newQuery 48 | */ 49 | const _dispatchQueryUpdate = newQuery => { 50 | 51 | if (props.onQueryChange) { 52 | const query = cloneDeep(newQuery); 53 | props.onQueryChange(query); 54 | } 55 | }; 56 | 57 | /** 58 | * When a rule group has changed one or more values, create a copy of the query, find the target rule group, 59 | * create a new rule group object and then update state values, dispatch changes to parent component 60 | * @param {object} updatedRuleGroup 61 | */ 62 | const handleRuleGroupChange = updatedRuleGroup => { 63 | const queryCopy = { ...query }; 64 | 65 | const targetGroup = findRuleOrGroup(updatedRuleGroup.id, queryCopy); 66 | 67 | Object.assign(targetGroup, updatedRuleGroup); 68 | 69 | setQuery(queryCopy); 70 | _dispatchQueryUpdate(queryCopy); 71 | }; 72 | 73 | /** 74 | * When a rule group is removed, create a copy of the query object, find the target rule group, 75 | * grab the target rule group index and the splice the current rules, removing the target group. 76 | * Update state values, dispatch changes to parent component 77 | * @param {string} parentGroupId 78 | * @param {string} id 79 | */ 80 | const handleRemoveRuleGroup = (parentGroupId, id) => { 81 | const queryCopy = { ...query } 82 | 83 | const parentGroup = findRuleOrGroup(parentGroupId, queryCopy); 84 | const index = parentGroup.rules.findIndex(item => item.id === id); 85 | 86 | parentGroup.rules.splice(index, 1); 87 | 88 | setQuery(queryCopy); 89 | _dispatchQueryUpdate(queryCopy); 90 | }; 91 | 92 | // Set the query state when a new query prop comes in 93 | useEffect(() => { 94 | setQuery(generateSimpleQuery(props.query || _getInitialQuery())); 95 | }, [props.query, _getInitialQuery]); 96 | 97 | 98 | // Notify a query change on mount 99 | useEffect(() => { 100 | _dispatchQueryUpdate(query); 101 | }, []); // eslint-disable-line 102 | 103 | return ( 104 |
105 | { 106 | query.rules.length && 107 | query.rules.map(ruleGroup => ( 108 | 118 | )) 119 | } 120 |
121 | ); 122 | }; 123 | 124 | QBuilder.defaultProps = { 125 | targets: [], 126 | fields: [], 127 | combinators: [], 128 | onQueryChange: null, 129 | useCustomStyles: false, 130 | query: null, 131 | }; 132 | 133 | QBuilder.propTypes = { 134 | targets: PropTypes.arrayOf(PropTypes.shape( 135 | { 136 | name: PropTypes.string.isRequired, 137 | value: PropTypes.string.isRequired, 138 | selectValues: PropTypes.arrayOf(PropTypes.shape({ 139 | text: PropTypes.string, 140 | value: PropTypes.string 141 | })) 142 | } 143 | )), 144 | fields: PropTypes.arrayOf(PropTypes.shape( 145 | { 146 | label: PropTypes.string.isRequired, 147 | name: PropTypes.string.isRequired, 148 | type: PropTypes.string, 149 | operators: PropTypes.arrayOf(PropTypes.string), 150 | data: PropTypes.array 151 | } 152 | )), 153 | combinators: PropTypes.array, 154 | onQueryChange: PropTypes.func, 155 | useCustomStyles: PropTypes.bool, 156 | query: PropTypes.object 157 | }; 158 | 159 | export default QBuilder; -------------------------------------------------------------------------------- /src/components/fields/AutoComplete.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import nextId from 'react-id-generator'; 4 | 5 | /** 6 | * @typedef {object} AutoCompleteProps 7 | * @property {string} value 8 | * @property {{name: string; value: string;}[]} data 9 | * @property {function} onValueChange 10 | */ 11 | 12 | /** 13 | * @param {AutoCompleteProps} props 14 | */ 15 | const AutoComplete = props => { 16 | 17 | const { 18 | data 19 | } = props; 20 | 21 | const initialValue = props.value ? data.find(item => item.value === props.value) : null; 22 | const [isSearching, setIsSearching] = useState(false); 23 | const [filteredData, setFilteredData] = useState(data || []); 24 | const [selectedDisplayValue, setSelectedDisplayValue] = useState(initialValue ? initialValue.name : ''); 25 | 26 | let searchInputRef = React.createRef(); 27 | let _timeoutID; 28 | 29 | 30 | /** 31 | * Checks to see if the entered search term matches the name or value in an item object 32 | * @param {{name: string; value:string;}} item 33 | * @param {string} searchTerm 34 | * @returns {boolean} - if search term was found in the list 35 | */ 36 | const _searchFilter = (item, searchTerm) => { 37 | searchTerm = searchTerm.toLowerCase(); 38 | 39 | return item.name.toLowerCase().includes(searchTerm) 40 | || item.name.toLowerCase() === searchTerm 41 | || (typeof item.includes !== 'undefined' && item.value.includes(searchTerm)) 42 | || item.value === searchTerm; 43 | }; 44 | 45 | /** 46 | * Informs parent of input value change 47 | * @param {string} value 48 | */ 49 | const _dispatchValueChange = value => { 50 | 51 | if (props.onValueChange) { 52 | props.onValueChange(value.toString()); 53 | } 54 | }; 55 | 56 | /** 57 | * Once a user selects a term from the drop down, we update state and dispatch the change notification 58 | * @param {{name: string; value: string;}} item 59 | */ 60 | const handleResultSelect = item => { 61 | setSelectedDisplayValue(item.name); 62 | _dispatchValueChange(item.value.toString()); 63 | setIsSearching(false); 64 | }; 65 | 66 | /** 67 | * When the autocomplete input is selected, we filter the existing autocomplete dropdown, 68 | * update state and focus the correct html input element 69 | */ 70 | const handleInputFocus = () => { 71 | setIsSearching(true); 72 | setFilteredData(data.filter(item => _searchFilter(item, selectedDisplayValue))); 73 | 74 | searchInputRef.current.focus(); 75 | }; 76 | 77 | /** 78 | * Clears the timeout object when autocomplete is focussed 79 | * @param {event} evt - the synthetic React event 80 | */ 81 | const handleAutoCompleteFocus = evt => { 82 | clearTimeout(_timeoutID); 83 | }; 84 | 85 | /** 86 | * When input is blurred, we create a timeout object to reset the search state value 87 | * which clears the UI of the autocomplete dropdown 88 | * @param {event} evt - the synthetic React event 89 | */ 90 | const handleAutoCompleteBlur = evt => { 91 | _timeoutID = setTimeout(() => { 92 | setIsSearching(false); 93 | }, 100); 94 | }; 95 | 96 | /** 97 | * When a user enters some input, update state and search through the supplied list 98 | * of autocomplete values to narrow down their search 99 | * @param {event} evt - the synthetic React event 100 | */ 101 | const handleValueChange = evt => { 102 | const searchValue = evt.target.value; 103 | const filteredDataCopy = data.filter(item => _searchFilter(item, searchValue)); 104 | 105 | setFilteredData(filteredDataCopy); 106 | setSelectedDisplayValue(searchValue); 107 | } 108 | 109 | return ( 110 |
111 | 119 |
125 | 132 |
    133 | { 134 | filteredData.map(item => ( 135 |
  • handleResultSelect(item)} 138 | > 139 | {item.name} 140 |
  • 141 | )) 142 | } 143 |
144 |
145 |
146 | ); 147 | }; 148 | 149 | AutoComplete.defaultProps = { 150 | value: '', 151 | onValueChange: null, 152 | data: [] 153 | }; 154 | 155 | AutoComplete.propTypes = { 156 | value: PropTypes.string, 157 | onValueChange: PropTypes.func, 158 | data: PropTypes.arrayOf(PropTypes.shape({ 159 | name: PropTypes.string, 160 | value: PropTypes.any 161 | })), 162 | }; 163 | 164 | export default AutoComplete; -------------------------------------------------------------------------------- /src/components/fields/DatePicker.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactDatePicker from 'react-datepicker'; 4 | import moment from 'moment'; 5 | import nextId from 'react-id-generator'; 6 | 7 | import 'react-datepicker/dist/react-datepicker.css'; 8 | 9 | // Helpers 10 | import { isNullOrEmpty } from '../../helpers/utils'; 11 | 12 | // Constants 13 | import OPERATORS from '../../constants/operators'; 14 | 15 | /** 16 | * @typedef {object} DatePickerProps 17 | * @property {string} operator 18 | * @property {string} value 19 | * @property {function} onValueChange 20 | */ 21 | 22 | /** 23 | * @typedef {object} dateObject 24 | * @property {string} startDate 25 | * @property {string} endDate 26 | * @property {number} plusDays 27 | */ 28 | 29 | /** 30 | * @param {DatePickerProps} props 31 | */ 32 | const DatePicker = props => { 33 | 34 | const defaultDateObj = { 35 | startDate: '', 36 | endDate: '', 37 | plusDays: 0, 38 | }; 39 | const [dateObj, setDateObj] = useState(!isNullOrEmpty(props.value) ? JSON.parse(props.value) : defaultDateObj) 40 | const [startDate, setStartDate] = useState(); 41 | const [endDate, setEndDate] = useState(); 42 | const [selectedNumber, setSelectedNumber] = useState(0); 43 | 44 | // Private methods 45 | /** 46 | * Shorthand to check if we're using the 'between' operator 47 | * @returns {boolean} 48 | */ 49 | const _useDoubleValue = () => { 50 | return props.operator && props.operator === OPERATORS.BETWEEN; 51 | }; 52 | 53 | /** 54 | * Converts the datepicker date format (long form navtive JS date), into a unix timestamp version 55 | * @param {string} date 56 | * @returns {date} - iso formatted unix timestamp in milliseconds 57 | */ 58 | const _getIsoDate = date => { 59 | const isoDate = moment(date).valueOf(); 60 | return moment(isoDate).isValid() ? isoDate : ''; 61 | }; 62 | 63 | /** 64 | * Creates an updated date value object and informs the parent component of the change 65 | * Updates state value 66 | * @param {dateObject} updatedDateObj 67 | */ 68 | const _dispatchValueChange = updatedDateObj => { 69 | const dateObjCopy = { 70 | ...dateObj, 71 | ...updatedDateObj 72 | }; 73 | 74 | setDateObj(dateObjCopy); 75 | 76 | if (props.onValueChange) { 77 | props.onValueChange(JSON.stringify(dateObjCopy)); 78 | } 79 | }; 80 | 81 | 82 | // Handlers 83 | /** 84 | * Converts the selected start date to an unix timestamp, and checks that the end date is _after_ the selected start date 85 | * If not, we set the end date to the selected start date 86 | * Update state and dispatch updated value(s) 87 | * @param {string} date 88 | */ 89 | const handleStartDateChange = date => { 90 | const updates = { startDate: _getIsoDate(date) }; 91 | 92 | // check if start date is _after_ end date and correct 93 | if (!isNullOrEmpty(endDate) && moment(date).isAfter(moment(endDate))) { 94 | updates.endDate = _getIsoDate(date); 95 | setEndDate(date); 96 | } 97 | 98 | setStartDate(date); 99 | _dispatchValueChange(updates); 100 | }; 101 | 102 | /** 103 | * Converts the selected end date to an unix timestamp, updates state and dispatches updated value 104 | * @param {*} date 105 | */ 106 | const handleEndDateChange = date => { 107 | const updates = { endDate: _getIsoDate(date) }; 108 | 109 | setEndDate(date); 110 | _dispatchValueChange(updates); 111 | }; 112 | 113 | /** 114 | * When a user enters some input, update state dispatch updated plus/minus days 115 | * @param {event} evt - the synthetic React event 116 | */ 117 | const handleValueChange = evt => { 118 | 119 | const updates = { plusDays: evt.target.value }; 120 | 121 | setSelectedNumber(evt.target.value); 122 | _dispatchValueChange(updates); 123 | }; 124 | 125 | 126 | return ( 127 | <> 128 | 136 | { 137 | _useDoubleValue() ? 138 | <> 139 | and 140 | 149 | 150 | : null 151 | } 152 | { 153 | props.operator && props.operator === OPERATORS.PLUS_MINUS_DAYS ? 154 | <> 155 | +/- days 156 | 164 | 165 | : null 166 | } 167 | 168 | ) 169 | }; 170 | 171 | DatePicker.defaultProps = { 172 | operator: '', 173 | value: '', 174 | onValueChange: null, 175 | }; 176 | 177 | DatePicker.propTypes = { 178 | operator: PropTypes.string, 179 | value: PropTypes.string, 180 | onValueChange: PropTypes.func, 181 | }; 182 | 183 | export default DatePicker -------------------------------------------------------------------------------- /src/components/Rule.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Components 5 | import DropdownSelect from './DropdownSelect'; 6 | import FieldContainer from './FieldContainer'; 7 | 8 | // Helpers 9 | import generateField from '../helpers/generateField'; 10 | import { isNullOrEmpty } from '../helpers/utils'; 11 | 12 | // Constants 13 | import PROP_TYPES from '../constants/propTypes'; 14 | import { DATA_TYPE_OPERATORS } from '../constants/operators'; 15 | 16 | /** 17 | * @typedef {object} RuleObj 18 | * @property {string} id 19 | * @property {string} field 20 | * @property {string} value 21 | * @property {string} operator 22 | */ 23 | /** 24 | * @typedef {object} RuleProps 25 | * @property {RuleObj} rule 26 | * @property {array} fields 27 | * @property {function} onRemoveRule 28 | */ 29 | 30 | /** 31 | * @param {RuleProps} props 32 | */ 33 | const Rule = props => { 34 | 35 | const { 36 | rule: initialRule, 37 | fields, 38 | } = props; 39 | 40 | const { 41 | id, 42 | field: initialField, 43 | value: initialValue, 44 | operator: initialOperator 45 | } = initialRule; 46 | 47 | const [selectedOperator, setSelectedOperator] = useState(initialOperator); 48 | const [selectedField, setSelectedField] = useState((fields && fields.find(field => field.name === initialField)) || generateField()); 49 | const [selectedValue, setSelectedValue] = useState(initialValue); 50 | const [fieldOperators, setFieldOperators] = useState([]); 51 | const [rule, setRule] = useState(initialRule); 52 | 53 | const fieldOptions = fields.map(field => ({ text: field.label, value: field.name })); 54 | 55 | /** 56 | * Given a field with a dataType property, grab the appropriate operators for that data type from 57 | * the DATA_TYPE_OPERATORS constant. Otherwise set the default list of operators. 58 | * If, however, the field object has an operators value set (i.e. an override), then use that instead 59 | * @param {object} field 60 | * @returns {string[]} - a string array of operators, such as ['or','not','between'] 61 | */ 62 | const _getFieldOperators = field => { 63 | let availableOperators = field.dataType ? DATA_TYPE_OPERATORS[field.dataType] : DATA_TYPE_OPERATORS.DEFAULT; 64 | 65 | if (field && field.operators) { 66 | availableOperators = field.operators; 67 | } 68 | 69 | return availableOperators; 70 | }; 71 | 72 | /** 73 | * Given an updated rule object we create a copy of the existing rule, merge the update object 74 | * update state and dispatch the changes to the parent component 75 | * @param {object} updatedRule 76 | */ 77 | const _dispatchRuleUpdate = updatedRule => { 78 | 79 | const ruleCopy = { 80 | ...rule, 81 | ...updatedRule 82 | }; 83 | 84 | setRule(ruleCopy); 85 | 86 | if (props.onRuleChange) { 87 | props.onRuleChange(ruleCopy); 88 | } 89 | }; 90 | 91 | /** 92 | * When the user changes the field drop down, find the selected field object, 93 | * work out the appropriate operators for that field. 94 | * Update state values, dispatch changes to parent component 95 | * @param {string} fieldName 96 | */ 97 | const handleFieldChange = fieldName => { 98 | const newSelectedField = fields.find(field => field.name === fieldName); 99 | const availableOperators = _getFieldOperators(newSelectedField); 100 | 101 | setSelectedField(newSelectedField); 102 | setFieldOperators(availableOperators); 103 | 104 | const updates = { field: fieldName }; 105 | 106 | _dispatchRuleUpdate(updates); 107 | }; 108 | 109 | /** 110 | * When the user changes the operators drop down, 111 | * update state values, dispatch changes to parent component 112 | * @param {string} operatorValue 113 | */ 114 | const handleFieldOperatorChange = operatorValue => { 115 | setSelectedOperator(operatorValue); 116 | 117 | const updates = { operator: operatorValue }; 118 | 119 | _dispatchRuleUpdate(updates); 120 | }; 121 | 122 | /** 123 | * When the user changes the selected field's value, 124 | * update state values, dispatch changes to parent component 125 | * @param {string} newFieldValue 126 | */ 127 | const handleFieldValueChange = newFieldValue => { 128 | setSelectedValue(newFieldValue); 129 | 130 | const updates = { value: newFieldValue }; 131 | 132 | _dispatchRuleUpdate(updates); 133 | }; 134 | 135 | // if there is an initial field set, force update the set field on component load 136 | useEffect(() => { 137 | if (!isNullOrEmpty(initialField)) { 138 | handleFieldChange(initialField); 139 | } 140 | }, []); // eslint-disable-line 141 | 142 | return ( 143 |
144 | 149 | 154 | 160 | 161 |
162 | ); 163 | }; 164 | 165 | Rule.defaultProps = { 166 | rule: { 167 | id: '', 168 | field: '', 169 | value: '', 170 | operator: '' 171 | }, 172 | fields: [], 173 | onRemoveRule: null, 174 | }; 175 | 176 | Rule.propTypes = { 177 | rule: PROP_TYPES.RULE, 178 | fields: PROP_TYPES.FIELDS, 179 | onRuleChange: PropTypes.func, 180 | onRemoveRule: PropTypes.func.isRequired 181 | }; 182 | 183 | export default Rule; -------------------------------------------------------------------------------- /src/components/RuleGroup.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Components 5 | import DropdownSelect from './DropdownSelect'; 6 | import Rule from './Rule'; 7 | 8 | // Helpers 9 | import generateRule from '../helpers/generateRule'; 10 | import generateRuleGroup from '../helpers/generateRuleGroup'; 11 | 12 | // Constants 13 | import PROP_TYPES from '../constants/propTypes'; 14 | 15 | /** 16 | * @typedef {object} RuleGroupObj 17 | * @property {string} id 18 | * @property {array} rules 19 | * @property {string} combinator 20 | * @property {boolean} not 21 | */ 22 | /** 23 | * @typedef {object} RuleGroupProps 24 | * @property {string} parentGroupId 25 | * @property {array} fields 26 | * @property {RuleGroupObj} ruleGroup 27 | * @property {array} combinators 28 | * @property {function} onRuleGroupChange 29 | * @property {function} onRemoveRuleGroup 30 | * @property {boolean} showRemoveButton 31 | */ 32 | 33 | /** 34 | * @param {RuleGroupProps} props 35 | */ 36 | const RuleGroup = props => { 37 | 38 | const { 39 | ruleGroup: initialRuleGroup, 40 | combinators, 41 | fields, 42 | showRemoveButton, 43 | parentGroupId 44 | } = props; 45 | 46 | const { 47 | id, 48 | combinator: initialCombinator, 49 | not 50 | } = initialRuleGroup; 51 | 52 | const combinatorValue = initialCombinator || (combinators && combinators[0] ? combinators[0] : ''); 53 | 54 | const [selectedCombinator, setCombinator] = useState(combinatorValue); 55 | const [ruleGroup, setRuleGroup] = useState(initialRuleGroup || generateRuleGroup({ combinator: combinatorValue })); 56 | 57 | /** 58 | * Given an updated rule group object we create a copy of the existing group, merge the update object 59 | * update state and dispatch the changes to the parent component 60 | * @param {object} updatedRuleGroup 61 | */ 62 | const _dispatchRuleGroupUpdate = updatedRuleGroup => { 63 | 64 | const ruleGroupCopy = { 65 | ...ruleGroup, 66 | ...updatedRuleGroup 67 | }; 68 | 69 | setRuleGroup(ruleGroupCopy); 70 | 71 | if (props.onRuleGroupChange) { 72 | props.onRuleGroupChange(ruleGroupCopy); 73 | } 74 | }; 75 | 76 | // Handle Rule changes -----------------------------------------------------// 77 | /** 78 | * When the user changes a combinator drop down, 79 | * update state values, dispatch changes to parent component 80 | * @param {string} selectedCombinator 81 | */ 82 | const handleOnCombinatorChange = selectedCombinator => { 83 | setCombinator(selectedCombinator); 84 | 85 | const updates = { combinator: selectedCombinator }; 86 | 87 | _dispatchRuleGroupUpdate(updates); 88 | }; 89 | 90 | /** 91 | * When the user adds a rule, we create a copy of the current group's rules, adding in an empty rule 92 | * from the 'generateRule()' method. 93 | * Update state values, dispatch changes to parent component 94 | */ 95 | const handleAddRule = () => { 96 | const rulesCopy = [ 97 | ...ruleGroup.rules, 98 | generateRule() 99 | ]; 100 | 101 | const updates = { rules: rulesCopy }; 102 | 103 | _dispatchRuleGroupUpdate(updates); 104 | }; 105 | 106 | /** 107 | * When the user removes or deletes a rule, filter this rule out of the existing rules by id value. 108 | * Update state values, dispatch changes to parent component 109 | * @param {string} id 110 | */ 111 | const handleRemoveRule = id => { 112 | const rulesCopy = ruleGroup.rules.filter(rule => rule.id !== id); 113 | const updates = { rules: rulesCopy }; 114 | 115 | _dispatchRuleGroupUpdate(updates); 116 | }; 117 | 118 | /** 119 | * Given an updated rule object we create a copy of the existing group's rules, 120 | * swapping out the existing, matching rule based on id, for the updated one. 121 | * Update state and dispatch the changes to the parent component 122 | * @param {object} updatedRuleGroup 123 | */ 124 | const handleRuleChange = updatedRule => { 125 | const rulesCopy = ruleGroup.rules.map(rule => rule.id === updatedRule.id ? updatedRule : rule); 126 | const updates = { rules: rulesCopy }; 127 | 128 | _dispatchRuleGroupUpdate(updates); 129 | }; 130 | 131 | 132 | // Handle Rule Group changes ----------------------------------------------- // 133 | /** 134 | * When the user adds a new group, we create a copy of the current group's rules, adding in an empty group 135 | * from the 'generateRuleGroup()' method. 136 | * Update state values, dispatch changes to parent component 137 | */ 138 | const handleAddRuleGroup = () => { 139 | const rulesCopy = [ 140 | ...ruleGroup.rules, 141 | generateRuleGroup() 142 | ]; 143 | 144 | const updates = { rules: rulesCopy }; 145 | 146 | _dispatchRuleGroupUpdate(updates); 147 | }; 148 | 149 | // Update default things like the combinator 150 | useEffect(() => { 151 | const ruleGroupCopy = { 152 | ...ruleGroup, 153 | ...{ 154 | combinator: combinatorValue, 155 | //rules: rules 156 | } 157 | } 158 | 159 | _dispatchRuleGroupUpdate(ruleGroupCopy); 160 | }, []); 161 | 162 | return ( 163 |
164 | 169 | 170 | 171 | { 172 | showRemoveButton ? 173 | 174 | : null 175 | } 176 | { 177 | ruleGroup.rules && ruleGroup.rules.map(rule => ( 178 | rule.hasOwnProperty('rules') ? 179 | 188 | : 189 | 196 | )) 197 | } 198 |
199 | ); 200 | }; 201 | 202 | RuleGroup.defaultProps = { 203 | parentGroupId: null, 204 | fields: [], 205 | ruleGroup: {}, 206 | combinators: [], 207 | onRuleGroupChange: null, 208 | onRemoveRuleGroup: null, 209 | showRemoveButton: true, 210 | }; 211 | 212 | RuleGroup.propTypes = { 213 | parentGroupId: PropTypes.string.isRequired, 214 | fields: PROP_TYPES.FIELDS, 215 | ruleGroup: PROP_TYPES.RULE_GROUP, 216 | combinators: PropTypes.array, 217 | onRuleGroupChange: PropTypes.func.isRequired, 218 | onRemoveRuleGroup: PropTypes.func.isRequired, 219 | }; 220 | 221 | 222 | export default RuleGroup; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query Builder 2 | 3 | Driven by a need for a more customised query builder tool built in React, I developed an open source query builder that will output a structured JSON query object comprised of rules, rule-groups and a number of field components depending on desired input type. 4 | 5 | ## Getting Started 6 | 7 | To get started simply add the query builder to your project like this: 8 | 9 | ``` 10 | npm i -D react-query-builder 11 | ``` 12 | 13 | ### Viewing a demo of the query builder 14 | 15 | You can [view a live demo](https://codesandbox.io/s/cool-fermi-yb1hb?fontsize=14&hidenavigation=1&theme=dark) of the project on CodeSandbox. 16 | 17 | Alternatively, if you wish to download the code and run the example locally, then you can do so by downloading the project, navigating your favourite terminal to the folder and running: 18 | 19 | ``` 20 | npm run start 21 | ``` 22 | 23 | ### Prerequisites 24 | 25 | This is a react-based project and assumes that you have React installed, at least version 16.8.0. 26 | 27 | ### Building your first query 28 | 29 | To begin with, you'll want to import the query builder project into your app or component: 30 | 31 | ``` 32 | import QBuilder from 'react-query-builder' 33 | ``` 34 | 35 | Next, you can render it wherever you wish like this: 36 | 37 | ```JavaScript 38 | 45 | ``` 46 | 47 | The component receives a number of props as described below. 48 | 49 | * **fields** - _required_ - an object comprising a list of (more details below). 50 | * **combinators** - _required_ - an array of strings that allow users to select how to combine their query fields. Typical combinators are 'and' and 'or'. 51 | * **onQueryChange** - _required_ - a function which will be called each time the query is updated and is passed a JSON object in args. 52 | * **useCustomStyles** - we provide a basic level of styling to the query builder that looks good without any intervention. If you'd prefer, however, to use your own styles then set this to true and style away! 53 | * **query** - an initial structured JSON query object that, when available, will be rendered as a visual query by the component. 54 | 55 | ![Query Builder basics](query-builder-screen.png) 56 | 57 | ### Additional exports 58 | 59 | 60 | The query builder also exports some helper string constants to aid in constructing the necessary schema to supply to the query builder, such as fields, operators and data types. 61 | 62 | ```JavaScript 63 | import { 64 | FIELD_TYPES, 65 | OPERATORS, 66 | COMBINATORS, 67 | DATA_TYPES 68 | } from 'react-query-builder'; 69 | ``` 70 | 71 | If you investigate the source files, you will discover more about what is contained within these constants, but in summary: 72 | 73 | * **FIELD_TYPES** - contains a list of types of input fields that a user can use to input a query value. For example, if you supply 'DATE' then the user will be able to use a datepicker (**defaults to TEXT**). 74 | * **OPERATORS** - this exposes a list of popular and common operators, such as 'in', 'equals', 'before', etc. These can be supplied with a field if you want to override default operators that are defined based on the field's data type. **Note, this is completely optional**. 75 | * **COMBINATORS** - a lot more restricted (i.e. we only have 'and' and 'or' for now), but exposes a list combinators that join each part of the query together, such as 'AND'. 76 | * **DATA_TYPES** - data types outlines a small list of types of data, such as number, string, boolean, etc. When supplying a data type with a field (see [Fields](#fields) below), they also pre-select a list of operators. For example, if you supply 'bool', a user's operator choice is limited to 'IS' and 'IS_NOT'. 77 | 78 | #### Fields 79 | 80 | 81 | The query builder should be supplied with an array of field objects. These fields populate the first select element in each rule of the query builder. They also dictate what operators are available to be selected depending on each field. 82 | 83 | At a basic level, all you need to supply is a 'label' and a 'name', but you can also supply a 'dataType' and 'type' properties to further control which options are available to your user. 84 | 85 | Here are the common properties that can be supplied to a field object: 86 | 87 | * **label** - This is purely cosmetic and is displayed to the user in the field select element. 88 | * **name** - Once selected by the user, the name of the field you set here is passed into the final query JSON returned by the component. 89 | * **dataType** - Quite handily, the data type property controls a preset list of operators bound to each type. For example, if you select 'string' here, the user will see different operators then if you set 'date'. For more explaination on data types, see the [data types section](#additional-exports) above. 90 | * **type** - The field type allows you to control what input the user sees to enter their query value. For example, if you choose 'autocomplete' and supply a list of data, the user can search for and find their value from an autocomplete list. **Note if not set, this defaults to a simple text input**. 91 | * **operators** - Completely optional, if you wish to override the operators for this field then you can supply a string array here which will populate the operators select element when this field is chosen. 92 | 93 | There are some more properties available, depending on the field type, but this document will be fleshed out as these components are built out. 94 | 95 | ```JavaScript 96 | const fields = [ 97 | // Basic field 98 | { 99 | label: 'Basic field', 100 | name: 'basicfield', 101 | }, 102 | // Selecting a data type and value type 103 | { 104 | label: 'First Name', 105 | name: 'firstName', 106 | dataType: DATA_TYPES.STRING, 107 | type: FIELD_TYPES.TEXT, 108 | } 109 | // A more custom example with specific operator overrides and data for the autocomplete list 110 | { 111 | label: 'Users', 112 | name: 'users', 113 | dataType: DATA_TYPES.STRING, 114 | type: FIELD_TYPES.AUTOCOMPLETE_LIST, 115 | operators: [ 116 | 'in', 117 | 'with', 118 | 'has not', 119 | 'anything you like' 120 | ], 121 | // The autocomplete field type requires a data property which is an object array of 122 | // name/value pairs 123 | data: [ 124 | { 125 | name: 'Abraham Lincoln', 126 | value: 123 127 | }, 128 | ... 129 | { 130 | name: 'Mr Black', 131 | value: 99 132 | }, 133 | ] 134 | } 135 | ]; 136 | ``` 137 | 138 | #### Combinators 139 | 140 | Combinators simply join each rule group together. It accepts an array of strings and will populate the combinators select box (see query builder example image above). 141 | 142 | ```JavaScript 143 | const combinators = [ 144 | COMBINATORS.AND, // using the exported string constants 145 | 'maybe' // using an ordinary string 146 | ]; 147 | ``` 148 | 149 | #### Query 150 | 151 | As well as exporting a JSON object as the query builds, the component also accepts a query object as a starting point. It might be that you have a previously built and saved query that you need to load and have represented visually. 152 | 153 | You can see an example of a simple query below. 154 | 155 | ```JavaScript 156 | const initialQuery = { 157 | "id": "q-eYzqvRpGZWXoJXTSZQAtF", 158 | "rules": [ 159 | { 160 | "id": "g-x2P0kgQPeepc9H_KF2VmA", 161 | "rules": [ 162 | { 163 | "id": "r-zVFiaM-ofW-yRzmWzrWAG", 164 | "field": "firstName", 165 | "value": "smith", 166 | "operator": "ends with" 167 | }, 168 | { 169 | "id": "r-Xwk620lbgiJCfKMXRiSD-", 170 | "field": "firstName", 171 | "value": "Janet", 172 | "operator": "!=" 173 | }, 174 | { 175 | "id": "g-0OJl1mefHvEJK8pwZaFYg", 176 | "rules": [ 177 | { 178 | "id": "r-MYoJoveeQxcoLhsrJNVu9", 179 | "field": "users", 180 | "value": "123", 181 | "operator": "is not" 182 | } 183 | ], 184 | "combinator": "OR", 185 | "not": false 186 | } 187 | ], 188 | "combinator": "AND", 189 | "not": false 190 | } 191 | ] 192 | } 193 | ``` 194 | 195 | ## Built With 196 | 197 | * [React](https://reactjs.org/) - A JavaScript library for building user interfaces 198 | 199 | ## Contributing 200 | 201 | If there's a feature you'd like to see implemented or have issues, or bugs then please send a detailed 202 | 203 | ## Versioning 204 | 205 | We use [SemVer](http://semver.org/) for versioning. 206 | 207 | ## Authors 208 | 209 | * **Rob Kendal** - [Find me on Twitter](https://twitter.com/kendalmintcode) 210 | 211 | You can also find more from Rob Kendal via [his website](https://robkendal.co.uk) and [his GitHub account](https://github.com/bpk68). 212 | 213 | ## License 214 | 215 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 216 | 217 | ## Acknowledgments 218 | 219 | * Big thanks to the excellent [query builder](https://github.com/sapientglobalmarkets/react-querybuilder) work from Sapient Global Markets for the initial inspiration for this project. 220 | 221 | --------------------------------------------------------------------------------