├── .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 | {labelText}
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 | You need to enable JavaScript to run this app.
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 |
46 | Please choose...
47 | {
48 | props.options.map(option =>
49 |
53 | {option.text || option}
54 |
55 | )
56 | }
57 |
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 | Load existing query
87 | Reset
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 | props.onRemoveRule(id)}>
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 | rule
170 | group
171 | {
172 | showRemoveButton ?
173 | props.onRemoveRuleGroup(parentGroupId, id)}>
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 | 
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 |
--------------------------------------------------------------------------------