├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── ci-build.yml │ ├── ci-testing.yml │ ├── docs.yml │ └── lint.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── dev ├── App.vue ├── GroupCtrlSlot.vue ├── Input.vue ├── Number.vue ├── RuleSlot.vue └── main.ts ├── dist ├── demo.html ├── query-builder-vue.common.js ├── query-builder-vue.common.js.map ├── query-builder-vue.umd.js ├── query-builder-vue.umd.js.map ├── query-builder-vue.umd.min.js └── query-builder-vue.umd.min.js.map ├── docs ├── .vuepress │ ├── .gitignore │ └── config.js ├── README.md ├── configuration.md ├── contributing.md ├── demos.md ├── getting-started.md └── styling.md ├── jest.config.js ├── package.json ├── postcss.config.js ├── public └── index.html ├── src ├── MergeTrap.ts ├── QueryBuilder.vue ├── QueryBuilderChild.vue ├── QueryBuilderGroup.vue ├── QueryBuilderRule.vue ├── grip-vertical-solid.svg ├── guards.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── shims-vuedraggable.d.ts └── types.ts ├── tests ├── components │ ├── App.vue │ └── Component.vue └── unit │ ├── colored-borders.spec.ts │ ├── drag-n-drop.spec.ts │ ├── max-depth.spec.ts │ ├── query-builder-child.spec.ts │ ├── query-builder.spec.ts │ ├── slots.spec.ts │ └── validation.spec.ts ├── tsconfig.json ├── types └── index.d.ts ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | }, 7 | 8 | extends: [ 9 | 'plugin:vue/essential', 10 | '@vue/airbnb', 11 | '@vue/typescript', 12 | ], 13 | 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 17 | 'class-methods-use-this': 'warn', 18 | 'no-use-before-define': ['error', { functions: false }], 19 | 'no-param-reassign': ['error', { props: false }], 20 | 'arrow-parens': ['error', 'as-needed'], 21 | }, 22 | 23 | parserOptions: { 24 | parser: '@typescript-eslint/parser', 25 | }, 26 | 27 | overrides: [ 28 | { 29 | files: [ 30 | '**/__tests__/*.{j,t}s?(x)', 31 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 32 | ], 33 | env: { 34 | jest: true, 35 | }, 36 | }, 37 | { 38 | files: ['**/*.ts', '**/*.vue'], 39 | rules: { 40 | 'no-unused-vars': 'off', 41 | '@typescript-eslint/no-unused-vars': ['error'], 42 | }, 43 | }, 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /.github/workflows/ci-build.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test-build: 13 | name: "CI Build Testing" 14 | timeout-minutes: 5 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - "ubuntu-latest" 22 | node: 23 | - "16" 24 | - "14" 25 | - "12" 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Setup Node.js version ${{ matrix.node }} 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: ${{ matrix.node }} 35 | cache: "yarn" 36 | 37 | - name: Install Dependencies 38 | run: yarn install --frozen-lockfile 39 | 40 | - name: Test Build 41 | run: yarn run build 42 | -------------------------------------------------------------------------------- /.github/workflows/ci-testing.yml: -------------------------------------------------------------------------------- 1 | name: CI Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test-unit: 13 | name: "CI Unit Testing" 14 | timeout-minutes: 5 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - "ubuntu-latest" 22 | node: 23 | - "16" 24 | - "14" 25 | - "12" 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Setup Node.js version ${{ matrix.node }} 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: ${{ matrix.node }} 35 | cache: "yarn" 36 | 37 | - name: Install Dependencies 38 | run: yarn install --frozen-lockfile 39 | 40 | - name: Unit Test 41 | run: yarn run test:unit --coverage --collectCoverageOnlyFrom ./src/** 42 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | name: Build docs 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | cache: "yarn" 18 | 19 | - name: Install Dependencies 20 | run: yarn install --frozen-lockfile 21 | 22 | - name: Build Docs 23 | run: yarn run docs:build 24 | 25 | - name: Upload artifact 26 | uses: actions/upload-artifact@v2 27 | with: 28 | name: api-docs 29 | path: docs/.vuepress/dist/ 30 | 31 | deploy: 32 | name: Deploy to GitHub Pages 33 | runs-on: ubuntu-latest 34 | environment: github-pages 35 | needs: build 36 | 37 | steps: 38 | - name: Download build artifact 39 | uses: actions/download-artifact@v2 40 | with: 41 | name: api-docs 42 | 43 | - name: Deploy docs 44 | uses: crazy-max/ghaction-github-pages@v2 45 | with: 46 | target_branch: gh-pages 47 | build_dir: . 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v2 22 | with: 23 | cache: "yarn" 24 | 25 | - name: Install Dependencies 26 | run: yarn install --frozen-lockfile 27 | 28 | - name: Lint code 29 | run: yarn run lint 30 | 31 | - name: Check Lock File Changes 32 | run: > 33 | yarn 34 | && echo "Listing changed files:" 35 | && git diff --name-only --exit-code 36 | && echo "No files changed during lint." 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rudolf Tucek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI Testing](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-testing.yml/badge.svg)](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-testing.yml) 2 | [![CI Build](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-build.yml/badge.svg)](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-build.yml) 3 | [![Lint](https://github.com/rtucek/vue-query-builder/actions/workflows/lint.yml/badge.svg)](https://github.com/rtucek/vue-query-builder/actions/workflows/lint.yml) 4 | [![npm version](https://img.shields.io/npm/v/query-builder-vue)](https://www.npmjs.com/package/query-builder-vue) 5 | [![MIT LICENSE](https://img.shields.io/npm/l/query-builder-vue)](https://github.com/rtucek/vue-query-builder/blob/master/LICENSE) 6 | 7 | # Vue Query Builder 8 | 9 | A query-builder for Vue. 10 | 11 | 12 | ## Demos 13 | 14 | Plenty of samples and use cases are covered in the 15 | [documentation](https://rtucek.github.io/vue-query-builder/demos.html). 16 | 17 | 18 | ## Features 19 | 20 | Key features: 21 | 22 | - Re-ordering of rules and groups with drag'n'drop. 23 | - Emphasizing groups with configurable colors. 24 | - Control maximum depth of nested groups. 25 | - Easy to customize with pure CSS and slots. 26 | - Layout can be serialized and restored. 27 | - Vuex compatible. 28 | - TypeScript support. 29 | 30 | 31 | ## Installation 32 | 33 | ```bash 34 | yarn add query-builder-vue 35 | npm install query-builder-vue 36 | ``` 37 | 38 | Follow the docs for [minimum 39 | configuration](https://rtucek.github.io/vue-query-builder/getting-started.html#usage). 40 | 41 | 42 | ## Contribution 43 | 44 | [Contribution guidelines](https://rtucek.github.io/vue-query-builder/contributing.html) are located 45 | in the documentation. 46 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /dev/App.vue: -------------------------------------------------------------------------------- 1 | 113 | 114 | 270 | 271 | 316 | -------------------------------------------------------------------------------- /dev/GroupCtrlSlot.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 46 | 47 | 66 | -------------------------------------------------------------------------------- /dev/Input.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /dev/Number.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 35 | -------------------------------------------------------------------------------- /dev/RuleSlot.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | 29 | 34 | -------------------------------------------------------------------------------- /dev/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | 4 | Vue.config.productionTip = false; 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | }).$mount('#app'); 9 | -------------------------------------------------------------------------------- /dist/demo.html: -------------------------------------------------------------------------------- 1 | 2 | query-builder-vue demo 3 | 4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 | 20 | -------------------------------------------------------------------------------- /docs/.vuepress/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Query Builder Vue', 3 | description: 'Query Builder Vue documentation', 4 | 5 | base: '/vue-query-builder/', 6 | 7 | themeConfig: { 8 | nav: [ 9 | ], 10 | 11 | repo: 'rtucek/vue-query-builder', 12 | docsDir: 'docs', 13 | editLinks: true, 14 | 15 | sidebar: [ 16 | ['/', 'Introduction'], 17 | ['/demos', 'Demos'], 18 | ['/getting-started', 'Getting Started'], 19 | ['/configuration', 'Configuration'], 20 | ['/styling', 'Styling'], 21 | ['/contributing', 'Contributing'], 22 | ], 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Vue Query Builder 2 | 3 | A Vue Query Builder library. 4 | 5 | 6 | ## About 7 | 8 | This library got heavily inspired by [Daniel Abernathy's](https://github.com/dabernathy89) awesome 9 | [vue-query-builder](https://github.com/dabernathy89/vue-query-builder) library, which got in turn 10 | inspired by jQuery's [Knockout Query Builder](https://kindohm.github.io/knockout-query-builder/). 11 | 12 | The intention behind building my own was adding some missing features such as providing TypeScript 13 | support and re-ordering with drag'n'drop. 14 | 15 | 22 | 23 | 24 | ## Features 25 | 26 | The query builder has the following key features: 27 | 28 | - Re-ordering of rules and groups with drag'n'drop. 29 | - Emphasizing groups with configurable colors. 30 | - Control maximum depth of nested groups. 31 | - Easy to customize with pure CSS and slots. 32 | - Layout can be serialized and restored. 33 | - Vuex compatible. 34 | - TypeScript support. 35 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The configuration is performed by the `config` prop. A single object, which shape is defined by the 4 | [QueryBuilderConfig interface](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L26). 5 | 6 | Below, we'll cover every key aspect of the config object. 7 | 8 | 9 | ## Operators 10 | 11 | The operators are used for allowing the users to choose how rules within a group should be 12 | evaluated. For instance, you may allow the users select classic boolean operators like *AND* and 13 | *OR* for a group. Additionally, you could also provide less common operators like *ONE OF* for 14 | requiring at least on condition is satisfied for considering an entire group as truthy. 15 | Along your existing operators, you may also provide the negated counterparts like *AND NOT*, *OR 16 | NOT* and *NONE OF* operators. 17 | 18 | Every operator is defined by an unique identifier and a visual text. 19 | 20 | 21 | ```js 22 | { 23 | operators: [ 24 | { 25 | name: 'AND', 26 | identifier: 'AND', 27 | }, 28 | { 29 | name: 'OR', 30 | identifier: 'OR', 31 | }, 32 | { 33 | name: 'OR NOT', 34 | identifier: 'OR_NOT', 35 | }, 36 | { 37 | name: 'AND NOT', 38 | identifier: 'AND_NOT', 39 | }, 40 | // ... 41 | ], 42 | } 43 | ``` 44 | 45 | 46 | ## Rules 47 | 48 | Rules are individual conditions within a group and must be defined as components, adhering to the 49 | [v-model API](https://vuejs.org/v2/guide/components.html#Using-v-model-on-Components). 50 | 51 | Every rule must have an unique identifier, a display name, the underlying component and optionally 52 | an initialization value. 53 | 54 | You may want to wrap an external library with a dedicated component for one of the following 55 | reasons: 56 | - The library doesn't support the v-model API. 57 | - The underlying component requires addition configuration or you may want to provide additional 58 | configuration with props. 59 | - You want to apply custom CSS. 60 | 61 | The `initialValue` may provide any primitive value (string, number or null), however any object or 62 | array must be constructed by a factory function. 63 | 64 | There are several options for assigning a component to a rule: 65 | - JavaScript modules 66 | - Pre-defined Vue Components 67 | - Inline the component's definition directly (requires compiler-included build for templates or use 68 | render functions directly) 69 | 70 | ::: tip 71 | Below, there's a very simple version of employing custom rules. A more advanced version with 72 | custom operators in rules is demonstrated 73 | [on CodeSandbox](https://codesandbox.io/s/slot-rule-advanced-01o6l?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.vue&theme=dark). 74 | ::: 75 | 76 | 77 | ```js 78 | import InputSelection from './InputSelection.vue'; 79 | 80 | Vue.component('NumberSelection', { 81 | template: ` 82 | 87 | `, 88 | props: [ 89 | 'value', 90 | ], 91 | computed: { 92 | model: { 93 | get() { 94 | return this.value; 95 | }, 96 | set(value) { 97 | this.$emit('input', value); 98 | }, 99 | }, 100 | }, 101 | }); 102 | 103 | { 104 | rules: [ 105 | { 106 | identifier: 'txt', 107 | name: 'Text Selection', 108 | component: InputSelection, 109 | initialValue: '', 110 | }, 111 | { 112 | identifier: 'num', 113 | name: 'Number Selection', 114 | component: 'NumberSelection', 115 | initialValue: 10, 116 | }, 117 | { 118 | identifier: 'other-num', 119 | name: 'Other Number Selection', 120 | component: { 121 | template: ` 122 | 127 | `, 128 | props: [ 129 | 'value', 130 | ], 131 | computed: { 132 | model: { 133 | get() { 134 | return this.value; 135 | }, 136 | set(value) { 137 | this.$emit('input', value); 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | ], 144 | } 145 | ``` 146 | 147 | 154 | 155 | 156 | ## Colors 157 | 158 | A complex, deep nested query, can quickly become confusing. In order to keep an overview, nested 159 | groups may be emphasized with colorful hints. 160 | 161 | The `colors` property should be a string array with a minimum length of at least 2, containing any 162 | valid CSS color definition. 163 | 164 | 165 | ```js 166 | { 167 | colors: [ 168 | 'hsl(88, 50%, 55%)', 169 | 'hsl(187, 100%, 45%)', 170 | 'hsl(15, 100%, 55%)', 171 | ], 172 | } 173 | ``` 174 | 175 | 182 | 183 | 184 | ## Sortable 185 | 186 | Thanks to the excellent [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable) library, the 187 | query builder supports re-sorting rules and groups with drag'n'drop. 188 | 189 | The sortable feature is disabled by default, however you may enable it simply by any [Sortable 190 | options](https://github.com/SortableJS/Sortable#options) on the dragging property. 191 | 192 | ::: warning 193 | There are 2 exceptions on the sortable options object: 194 | 195 | - `groups` are ignored. The main reason is that the query builder will have set the value internally 196 | for allowing nested dragging. 197 | 198 | - All methods with `on` are ignored. From 199 | [Vue.Sortable's documentation](https://github.com/SortableJS/Vue.Draggable#all-sortable-options): 200 | > [...] 201 | > 202 | > This means that all sortable option are valid sortable props with the notable exception of all 203 | > the method starting by "on" as draggable component expose the same API via events. 204 | > 205 | > [...] 206 | ::: 207 | 208 | ```js 209 | { 210 | dragging: { 211 | animation: 300, 212 | disabled: false, 213 | dragClass: "sortable-drag" 214 | } 215 | } 216 | ``` 217 | 218 | 225 | 226 | 227 | ## Max-Depth 228 | 229 | If `typeof maxDepth === 'undefined'`, users may have an arbitrary depth of nested groups. 230 | 231 | For `typeof maxDepth === 'number' and 0 <= n <= maxDepth`, users are only allowed to create up to n 232 | nested groups. If n is 0, users are effectively not allowed to create any groups at all. 233 | 234 | 235 | ### Runtime change 236 | 237 | It is possible and valid to change `maxDepth` at runtime. 238 | As a special chase, if the given query has a higher nested depth and a config change restricts the 239 | `maxDepth` to a lower depth, the library (intentionally) removes any child groups, exceeding the 240 | present config limit. This ensures consistency with the `maxDepth` policy. It's the component user's 241 | responsibility of checking if setting `maxDepth` may not result in an unwanted side-effect of 242 | removing any child-groups from the given query tree. 243 | 244 | 245 | ### Usage of Sortable 246 | 247 | If drag'n'drop is activated via [Sortable](#sortable) config: 248 | Prior dropping a dragged group into another group, the library checks if the max depth policy would 249 | be violated and prevents dropping. The user will notice that the drag'n'drop preview will not adjust 250 | as usually expected. 251 | 252 | 253 | ### Usage of groupCtrlSlotProps 254 | 255 | For the slot of type [`groupCtrlSlotProps`](styling.html#groupcontrol-slot), the `newGroup()` 256 | callback, passed as slot prop, becomes a noop, if a group has exceeded the `maxDepth` policy. 257 | Additionally, a boolean flag with a `maxDepthExeeded` property is provided to the slot prop object, 258 | so the slot can check and hide a create-new-group handler. 259 | 260 | 266 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and appreciated. 4 | 5 | Please mind the following rules: 6 | 7 | If fixing bug: 8 | * Provide a detailed description of the bug in the PR. Live demo preferred. 9 | * Add appropriate test coverage if applicable. 10 | 11 | If adding a new feature: 12 | * Add accompanying test case. 13 | * Add documentation. 14 | * Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first 15 | and have it approved before working on it. 16 | * **DO NOT** commit build files for either library nor documentation. 17 | -------------------------------------------------------------------------------- /docs/demos.md: -------------------------------------------------------------------------------- 1 | # Demos 2 | 3 | The Query Builder has been designed with 2 goals in mind: allowing support of arbitrary components 4 | for selecting values and easy custom styling. 5 | 6 | The following samples demonstrate the capabilities of the library. 7 | 8 | 9 | ## Custom Components 10 | 11 | The Vue Query Builder may be combined with any library, supporting the [v-model 12 | API](https://vuejs.org/v2/guide/components.html#Using-v-model-on-Components). 13 | 14 | In the sample below, we're demonstrating usage of 3 different libraries: range selection 15 | (vue-range-component), select2 (vue-select) and a date-picker (vue-flatpickr-component). 16 | 17 | 18 | 25 | 26 | 27 | ## Theming 28 | 29 | Many work has been put into the ability of overriding the styling and markup for making the Query 30 | Builder agnostic to various CSS frameworks and responsive. Of course, you'll have to ensure by 31 | yourself that Query Builder rules are responsive. 32 | 33 | 34 | ### Bootstrap 35 | 36 | Query Builder with [Bootstrap 4](https://getbootstrap.com/) theme. 37 | 38 | 45 | 46 | 47 | ### Bulma 48 | 49 | Query Builder with [Bulma](https://bulma.io/) theme. 50 | 51 | 58 | 59 | 60 | ### Tailwind CSS 61 | 62 | Query Builder with [Tailwind CSS](https://tailwindcss.com/) theme. 63 | 64 | 71 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | 4 | ## Installation 5 | 6 | ::: warning 7 | Please be aware of that at **least Vue v2.6** is required, due to the new scoped slots features that 8 | this library is making use of. 9 | ::: 10 | 11 | 12 | Add the library with either npm or yarn - simply run one of the following commands: 13 | 14 | 15 | ```bash 16 | yarn add query-builder-vue 17 | npm install query-builder-vue 18 | ``` 19 | 20 | You may also use the pre-transpiled dist files from the Unpkg.com CDN - simply add, but don't forget 21 | to import Vue too! 22 | 23 | 24 | ```html 25 | 26 | ``` 27 | 28 | 29 | ## Usage 30 | 31 | Getting started with the QueryBuilder is easy. 32 | It just requires a minimum configuration. 33 | 34 | 41 | 42 | 43 | ```html 44 | 49 | 50 | 94 | 95 | 107 | ``` 108 | -------------------------------------------------------------------------------- /docs/styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | The Query Builder's default markup and styling has been consciously been kept simple for maximum 4 | customizability. 5 | 6 | Support for styling with slots is available. Below, the following slots may be used for seamless 7 | styling. 8 | 9 | A slot may be a simple inline template or a dedicated component. While inline templates allow for 10 | simplicity, dedicated components allow maintaining an internal state and you may use all of Vue's 11 | features, including methods, computed properties and watchers. 12 | 13 | :::tip 14 | By instinct, you may would like to us `v-model` on some of the slot props. However, this is not 15 | supported by Vue. The props contain a callback which shall be used instead for updating a value. 16 | 17 | Often, you'll have to use `v-bind:value` and `v-on:input` instead. 18 | ::: 19 | 20 | 21 | ## groupOperator Slot 22 | 23 | The `groupOperator` slot may be used for changing the markup of a group's operator. 24 | 25 | The slot receives an object with the shape of the [GroupOperatorSlotProps 26 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L34). 27 | 28 | ```vue 29 | 53 | 54 | 60 | ``` 61 | 62 | 69 | 70 | 71 | ## groupControl slot 72 | 73 | The `groupControl` slot allows for creating a new group or adding a new rule. 74 | 75 | The slot receives an object with the shape of the [GroupCtrlSlotProps 76 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L40). 77 | 78 | 85 | 86 | 87 | ## rule slot 88 | 89 | The `rule` slot allows for customizing markup around each rule component. 90 | 91 | The slot receives an object with the shape of the [RuleSlotProps 92 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L47). 93 | An exact rule can be identified based on the `ruleCtrl.ruleIdentifier` for dynamic content. 94 | 95 | You'll have to use Vue's [Dynamic 96 | Component](https://vuejs.org/v2/guide/components.html#Dynamic-Components) feature for displaying the 97 | actual rule component. 98 | 99 | ```vue{10-14} 100 | 105 | 106 | 116 | ``` 117 | 118 | 125 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-builder-vue", 3 | "version": "1.2.0", 4 | "license": "MIT", 5 | "repository": "https://github.com/rtucek/vue-query-builder", 6 | "description": "A query-builder library for Vue.js", 7 | "author": { 8 | "name": "Rudolf Tucek", 9 | "url": "https://github.com/rtucek/" 10 | }, 11 | "keywords": [ 12 | "drag'n'drop", 13 | "Query Builder", 14 | "themeable", 15 | "TypeScript", 16 | "Vue", 17 | "Vuex support" 18 | ], 19 | "private": false, 20 | "scripts": { 21 | "preversion-test": "echo \"Running tests for version $npm_package_version...\" && yarn run test:unit", 22 | "preversion": "yarn run preversion-test", 23 | "serve": "vue-cli-service serve dev/main.ts", 24 | "build": "vue-cli-service build --target lib src/QueryBuilder.vue", 25 | "test:unit": "vue-cli-service test:unit", 26 | "lint": "vue-cli-service lint src/ tests/ dev/", 27 | "docs:dev": "vuepress dev docs", 28 | "docs:build": "vuepress build docs" 29 | }, 30 | "main": "dist/query-builder-vue.umd.js", 31 | "browser": "dist/query-builder-vue.common.js", 32 | "jsdelivr": "dist/query-builder-vue.umd.min.js", 33 | "unpkg": "dist/query-builder-vue.umd.min.js", 34 | "types": "./types/index.d.ts", 35 | "files": [ 36 | "/dist/*.js", 37 | "/dist/*.js.map", 38 | "/types/*.d.ts" 39 | ], 40 | "dependencies": { 41 | "@types/sortablejs": "^1.10.2", 42 | "core-js": "^3.3.2", 43 | "sortablejs": "^1.11.2-alpha.4", 44 | "vue": "^2.6.10", 45 | "vue-class-component": "^7.2.6", 46 | "vue-property-decorator": "^9.0.0", 47 | "vuedraggable": "^2.24.1" 48 | }, 49 | "devDependencies": { 50 | "@types/jest": "^27.0.1", 51 | "@typescript-eslint/eslint-plugin": "^4.1.1", 52 | "@typescript-eslint/parser": "^4.1.1", 53 | "@vue/cli-plugin-babel": "^4.0.5", 54 | "@vue/cli-plugin-eslint": "^4.0.5", 55 | "@vue/cli-plugin-typescript": "^4.0.5", 56 | "@vue/cli-plugin-unit-jest": "^4.1.2", 57 | "@vue/cli-service": "^4.0.5", 58 | "@vue/eslint-config-airbnb": "^5.1.0", 59 | "@vue/eslint-config-typescript": "^7.0.0", 60 | "@vue/test-utils": "^1.1.0", 61 | "babel-eslint": "^10.0.1", 62 | "eslint": "^7.9.0", 63 | "eslint-plugin-vue": "^7.1.0", 64 | "lint-staged": "^11.1.2", 65 | "sass": "^1.49.9", 66 | "sass-loader": "^10.2.0", 67 | "typescript": "^4.0.3", 68 | "vue-template-compiler": "^2.5.21", 69 | "vuepress": "^1.2.0" 70 | }, 71 | "gitHooks": { 72 | "pre-commit": "lint-staged" 73 | }, 74 | "lint-staged": { 75 | "*.{js,vue,ts}": [ 76 | "vue-cli-service lint" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | query-builder 8 | 9 | 10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/MergeTrap.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { 3 | QueryBuilderGroupSym, 4 | RuleSet, QueryBuilderGroup, ComponentRegistration, MergeTrap as MergeTrapInterface, Rule, 5 | } from '@/types'; 6 | 7 | function getNextGroup(group: QueryBuilderGroup): QueryBuilderGroup { 8 | if (group.depth < 1) { 9 | return group; 10 | } 11 | 12 | let vm: Vue = group; 13 | 14 | do { 15 | vm = vm.$parent; 16 | } while ((vm as QueryBuilderGroup).type !== QueryBuilderGroupSym); 17 | 18 | return vm as QueryBuilderGroup; 19 | } 20 | 21 | function getCommonAncestor( 22 | nodeA: QueryBuilderGroup, 23 | nodeB: QueryBuilderGroup, 24 | ): QueryBuilderGroup { 25 | let a = nodeA; 26 | let b = nodeB; 27 | 28 | if (a.depth !== b.depth) { 29 | let lower: QueryBuilderGroup = a.depth > b.depth ? a : b; 30 | const higher: QueryBuilderGroup = a.depth < b.depth ? a : b; 31 | 32 | while (lower.depth !== higher.depth) { 33 | lower = getNextGroup(lower); 34 | } 35 | 36 | // Now both operate on the same level. 37 | a = lower; 38 | b = higher; 39 | } 40 | 41 | while (a !== b) { 42 | a = getNextGroup(a); 43 | b = getNextGroup(b); 44 | } 45 | 46 | return a; 47 | } 48 | 49 | function triggerUpdate(adder: ComponentRegistration, remover: ComponentRegistration): void { 50 | const commonAncestor = getCommonAncestor(adder.component, remover.component); 51 | 52 | if (![adder.component, remover.component].includes(commonAncestor)) { 53 | mergeViaParent(commonAncestor, adder, remover); 54 | 55 | return; 56 | } 57 | 58 | mergeViaNode(commonAncestor, adder, remover); 59 | } 60 | 61 | function mergeViaParent( 62 | commonAncestor: QueryBuilderGroup, 63 | adder: ComponentRegistration, 64 | remover: ComponentRegistration, 65 | ): void { 66 | let children: Array | null = null; 67 | 68 | commonAncestor.trap = (position: number, newChild: RuleSet | Rule): void => { 69 | if (children === null) { 70 | children = [...commonAncestor.children]; 71 | children.splice(position, 1, newChild); 72 | 73 | return; 74 | } 75 | 76 | commonAncestor.trap = null; 77 | 78 | children.splice(position, 1, newChild); 79 | 80 | commonAncestor.$emit( 81 | 'query-update', 82 | { 83 | operatorIdentifier: commonAncestor.selectedOperator, 84 | children, 85 | } as RuleSet, 86 | ); 87 | }; 88 | 89 | adder.component.$emit('query-update', adder.ev); 90 | remover.component.$emit('query-update', remover.ev); 91 | } 92 | 93 | function mergeViaNode( 94 | parentEmitter: QueryBuilderGroup, 95 | adder: ComponentRegistration, 96 | remover: ComponentRegistration, 97 | ): void { 98 | const childEmitter = parentEmitter === adder.component ? remover : adder; 99 | const children = [...parentEmitter.children]; 100 | 101 | parentEmitter.trap = (position: number, newChild: RuleSet | Rule): void => { 102 | parentEmitter.trap = null; // Release trap 103 | children.splice(position, 1, newChild); // First... accept the update from the child 104 | 105 | // Now we'd need to know if the update on the parent is an add or remove action. 106 | if (parentEmitter === adder.component) { 107 | // Parent emitter is adding and child is removing an item. 108 | // 109 | // First, use the event from the child to patch the current state (see above), 110 | // then use the state from the adder for inserting at the right idx. 111 | children.splice(adder.affectedIdx, 0, adder.ev.children[adder.affectedIdx]); 112 | } else { 113 | // Parent emitter is removing and child is adding an item. 114 | // 115 | // Use the event from the child to patch the current state (see above), 116 | // then use the state from the remover to remove the correct item. 117 | children.splice(remover.affectedIdx, 1); 118 | } 119 | 120 | parentEmitter.$emit( 121 | 'query-update', 122 | { 123 | operatorIdentifier: parentEmitter.selectedOperator, 124 | children, 125 | } as RuleSet, 126 | ); 127 | }; 128 | 129 | childEmitter.component.$emit('query-update', childEmitter.ev); 130 | } 131 | 132 | export default class MergeTrap implements MergeTrapInterface { 133 | private eventBus: Vue 134 | 135 | constructor() { 136 | this.eventBus = new Vue(); 137 | 138 | Promise.all([ 139 | new Promise(res => this.eventBus.$once('adder-registered', res)), 140 | new Promise(res => this.eventBus.$once('remover-registered', res)), 141 | ]) 142 | .then((args: ComponentRegistration[]) => triggerUpdate(args[0], args[1])); 143 | } 144 | 145 | public registerSortUpdate(update: ComponentRegistration): void { 146 | if (update.adding) { 147 | return this.registerAdder(update); 148 | } 149 | 150 | return this.registerRemover(update); 151 | } 152 | 153 | protected registerAdder(ev: ComponentRegistration): void { 154 | this.eventBus.$emit('adder-registered', ev); 155 | } 156 | 157 | protected registerRemover(ev: ComponentRegistration): void { 158 | this.eventBus.$emit('remover-registered', ev); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/QueryBuilder.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 108 | 109 | 115 | -------------------------------------------------------------------------------- /src/QueryBuilderChild.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 105 | 106 | 135 | -------------------------------------------------------------------------------- /src/QueryBuilderGroup.vue: -------------------------------------------------------------------------------- 1 | 377 | 378 | 483 | 484 | 546 | -------------------------------------------------------------------------------- /src/QueryBuilderRule.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 96 | 97 | 130 | -------------------------------------------------------------------------------- /src/grip-vertical-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/guards.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Rule, RuleSet, QueryBuilderConfig, OperatorDefinition, RuleDefinition, 3 | } from '@/types'; 4 | 5 | export function isRule(param: any): param is Rule { 6 | if (typeof param !== 'object' || param === null) { 7 | return false; 8 | } 9 | 10 | if (typeof param.identifier !== 'string') { 11 | return false; 12 | } 13 | 14 | const { hasOwnProperty } = Object.prototype; 15 | 16 | return hasOwnProperty.call(param, 'value'); 17 | } 18 | 19 | export function isRuleSet(param: any): param is RuleSet { 20 | if (typeof param !== 'object' || param === null) { 21 | return false; 22 | } 23 | 24 | if (typeof param.operatorIdentifier !== 'string') { 25 | return false; 26 | } 27 | 28 | return Array.isArray(param.children) 29 | && param.children.every((child: any) => isRule(child) || isRuleSet(child)); 30 | } 31 | 32 | export function isOperatorDefinition(param: any): param is OperatorDefinition { 33 | if (typeof param !== 'object' || param === null) { 34 | return false; 35 | } 36 | 37 | if (typeof param.identifier !== 'string') { 38 | return false; 39 | } 40 | 41 | return typeof param.name === 'string'; 42 | } 43 | 44 | export function isRuleDefinition(param: any): param is RuleDefinition { 45 | if (typeof param !== 'object' || param === null) { 46 | return false; 47 | } 48 | 49 | if (typeof param.identifier !== 'string') { 50 | return false; 51 | } 52 | 53 | if (typeof param.name !== 'string') { 54 | return false; 55 | } 56 | 57 | return ['function', 'object', 'string'].includes(typeof param.component); 58 | } 59 | 60 | export function isQueryBuilderConfig(param: any): param is QueryBuilderConfig { 61 | if (typeof param !== 'object' || param === null) { 62 | return false; 63 | } 64 | 65 | return Array.isArray(param.operators) 66 | && param.operators.every((operator: any) => isOperatorDefinition(operator)) 67 | && Array.isArray(param.rules) 68 | && param.rules.every((rule: any) => isRuleDefinition(rule)) 69 | && ( 70 | !param.colors 71 | || ( 72 | Array.isArray(param.colors) 73 | && param.colors.every((color: any) => typeof color === 'string') 74 | ) 75 | ) 76 | && ( 77 | typeof param.maxDepth === 'undefined' // optional config value not present 78 | || ( 79 | typeof param.maxDepth === 'number' 80 | && param.maxDepth >= 0 81 | ) 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | 4 | export default Vue; 5 | } 6 | -------------------------------------------------------------------------------- /src/shims-vuedraggable.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vuedraggable' { 2 | type element = { 3 | element: El, 4 | } 5 | 6 | type Older = { 7 | oldIndex: number, 8 | } 9 | 10 | type Newer = { 11 | newIndex: number, 12 | } 13 | 14 | export type Moved = element & Older & Newer 15 | 16 | export type Added = element & Newer 17 | 18 | export type Removed = element & Older 19 | 20 | export type ChangeEvent = { 21 | moved?: Moved, 22 | added?: Added, 23 | removed?: Removed, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import Vue, { Component } from 'vue'; 2 | import { SortableOptions } from 'sortablejs'; 3 | 4 | export interface Rule { 5 | identifier: string, 6 | value: any, 7 | } 8 | 9 | export interface RuleSet { 10 | operatorIdentifier: string, 11 | children: Array, 12 | } 13 | 14 | export interface OperatorDefinition { 15 | identifier: string, 16 | name: string, 17 | } 18 | 19 | export interface RuleDefinition { 20 | identifier: string, 21 | name: string, 22 | component: Component | string, 23 | initialValue?: any, 24 | } 25 | 26 | export interface QueryBuilderConfig { 27 | operators: OperatorDefinition[], 28 | rules: RuleDefinition[], 29 | maxDepth?: number, 30 | colors?: string[], 31 | dragging?: SortableOptions, 32 | } 33 | 34 | export interface GroupOperatorSlotProps { 35 | currentOperator: string, 36 | operators: OperatorDefinition[], 37 | updateCurrentOperator: (newOperator: string) => void, 38 | } 39 | 40 | export interface GroupCtrlSlotProps { 41 | maxDepthExeeded: boolean, 42 | rules: RuleDefinition[], 43 | addRule: (newRule: string) => void, 44 | newGroup: () => void, 45 | } 46 | 47 | export interface RuleSlotProps { 48 | ruleComponent: Component | string, 49 | ruleData: any, 50 | ruleIdentifier: string, 51 | updateRuleData: (newData: any) => void, 52 | } 53 | 54 | export const QueryBuilderGroupSym = Symbol('QueryBuilderGroup'); 55 | 56 | export interface QueryBuilderGroup extends Vue { 57 | selectedOperator: string, 58 | depth: number, 59 | trap: ((position: number, newChild: RuleSet | Rule) => void) | null, 60 | children: Array, 61 | type: Symbol, 62 | } 63 | 64 | export interface ComponentRegistration { 65 | component: QueryBuilderGroup, 66 | ev: RuleSet, 67 | adding: boolean, 68 | affectedIdx: number, 69 | } 70 | 71 | export interface MergeTrap { 72 | registerSortUpdate(update: ComponentRegistration): void, 73 | } 74 | -------------------------------------------------------------------------------- /tests/components/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | -------------------------------------------------------------------------------- /tests/components/Component.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /tests/unit/colored-borders.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Draggable from 'vuedraggable'; 3 | import { QueryBuilderConfig, RuleSet } from '@/types'; 4 | import QueryBuilder from '@/QueryBuilder.vue'; 5 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue'; 6 | import QueryBuilderChild from '@/QueryBuilderChild.vue'; 7 | import Component from '../components/Component.vue'; 8 | 9 | describe('Testing drag\'n\'drop related features', () => { 10 | const config: QueryBuilderConfig = { 11 | operators: [ 12 | { 13 | name: 'and', 14 | identifier: 'AND', 15 | }, 16 | { 17 | name: 'or', 18 | identifier: 'OR', 19 | }, 20 | ], 21 | rules: [ 22 | { 23 | identifier: 'txt', 24 | name: 'Text Selection', 25 | component: Component, 26 | initialValue: 'foo', 27 | }, 28 | { 29 | identifier: 'num', 30 | name: 'Number Selection', 31 | component: Component, 32 | initialValue: 10, 33 | }, 34 | ], 35 | }; 36 | 37 | const value: RuleSet = { 38 | operatorIdentifier: 'OR', 39 | children: [{ 40 | operatorIdentifier: 'AND', 41 | children: [{ 42 | identifier: 'txt', 43 | value: 'A', 44 | }, { 45 | identifier: 'txt', 46 | value: 'B', 47 | }, { 48 | identifier: 'txt', 49 | value: 'C', 50 | }, { 51 | operatorIdentifier: 'AND', 52 | children: [{ 53 | identifier: 'txt', 54 | value: 'c', 55 | }, { 56 | identifier: 'txt', 57 | value: 'd', 58 | }, { 59 | operatorIdentifier: 'AND', 60 | children: [{ 61 | identifier: 'txt', 62 | value: 'a', 63 | }, { 64 | identifier: 'txt', 65 | value: 'b', 66 | }], 67 | }], 68 | }], 69 | }, { 70 | operatorIdentifier: 'AND', 71 | children: [{ 72 | identifier: 'txt', 73 | value: 'X', 74 | }, { 75 | identifier: 'txt', 76 | value: 'Y', 77 | }, { 78 | identifier: 'txt', 79 | value: 'Z', 80 | }, { 81 | operatorIdentifier: 'AND', 82 | children: [{ 83 | identifier: 'txt', 84 | value: '', 85 | }, { 86 | operatorIdentifier: 'AND', 87 | children: [{ 88 | identifier: 'txt', 89 | value: '', 90 | }, { 91 | operatorIdentifier: 'AND', 92 | children: [{ 93 | operatorIdentifier: 'AND', 94 | children: [{ 95 | identifier: 'txt', 96 | value: '', 97 | }, { 98 | identifier: 'num', 99 | value: 10, 100 | }], 101 | }], 102 | }], 103 | }], 104 | }], 105 | }], 106 | }; 107 | 108 | it('asserts nothing happens if colors are not configured', () => { 109 | const app = mount(QueryBuilder, { 110 | propsData: { 111 | value, 112 | config, 113 | }, 114 | }); 115 | 116 | const groups = app.findAllComponents(QueryBuilderGroup); 117 | expect(groups).toHaveLength(9); 118 | 119 | groups.wrappers 120 | .forEach(w => { 121 | expect(w.vm.$props).toHaveProperty('depth'); 122 | const el = (w.findComponent(QueryBuilderChild)).element as HTMLDivElement; 123 | expect(el.style.borderColor).toBeFalsy(); 124 | }); 125 | }); 126 | 127 | it('checks border colors are applied properly', () => { 128 | const colors = [ 129 | 'hsl(88, 50%, 55%)', 130 | 'hsl(187, 100%, 45%)', 131 | 'hsl(15, 100%, 55%)', 132 | ]; 133 | const newConfig: QueryBuilderConfig = { ...config, colors }; 134 | 135 | const app = mount(QueryBuilder, { 136 | propsData: { 137 | value, 138 | config: newConfig, 139 | }, 140 | }); 141 | 142 | const groups = app.findAllComponents(QueryBuilderGroup); 143 | expect(groups).toHaveLength(9); 144 | 145 | groups.wrappers 146 | .forEach(w => { 147 | expect(w.vm.$props).toHaveProperty('depth'); 148 | const el = (w.findComponent(Draggable)).element as HTMLDivElement; 149 | const targetIdx = w.vm.$props.depth % w.vm.$props.config.colors.length; 150 | expect(el.style).toHaveProperty('borderColor', colors[targetIdx]); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/unit/drag-n-drop.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Draggable, { ChangeEvent } from 'vuedraggable'; 3 | import QueryBuilder from '@/QueryBuilder.vue'; 4 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue'; 5 | import { 6 | RuleSet, Rule, QueryBuilderConfig, QueryBuilderGroup as QueryBuilderGroupInterface, 7 | } from '@/types'; 8 | import Component from '../components/Component.vue'; 9 | 10 | // Schedule a microtask, so all pending promises can be executed 11 | const flushPromises = (): Promise => new Promise(res => setTimeout(res, 0)); 12 | 13 | describe('Test drag\'n\'drop related actions', () => { 14 | const value: RuleSet = { 15 | operatorIdentifier: 'OR', 16 | children: [{ 17 | operatorIdentifier: 'AND', 18 | children: [{ 19 | identifier: 'txt', 20 | value: 'A', 21 | }, { 22 | identifier: 'txt', 23 | value: 'B', 24 | }, { 25 | identifier: 'txt', 26 | value: 'C', 27 | }, { 28 | operatorIdentifier: 'AND', 29 | children: [{ 30 | identifier: 'txt', 31 | value: 'D', 32 | }, { 33 | identifier: 'txt', 34 | value: 'E', 35 | }, { 36 | operatorIdentifier: 'AND', 37 | children: [{ 38 | identifier: 'txt', 39 | value: 'F', 40 | }, { 41 | operatorIdentifier: 'AND', 42 | children: [{ 43 | identifier: 'txt', 44 | value: 'G', 45 | }], 46 | }, { 47 | identifier: 'txt', 48 | value: 'H', 49 | }], 50 | }], 51 | }], 52 | }, { 53 | operatorIdentifier: 'AND', 54 | children: [{ 55 | identifier: 'txt', 56 | value: 'X', 57 | }, { 58 | operatorIdentifier: 'AND', 59 | children: [{ 60 | identifier: 'txt', 61 | value: 'T', 62 | }], 63 | }, { 64 | identifier: 'txt', 65 | value: 'Y', 66 | }, { 67 | identifier: 'txt', 68 | value: 'Z', 69 | }], 70 | }], 71 | }; 72 | 73 | const config: QueryBuilderConfig = { 74 | operators: [ 75 | { 76 | name: 'AND', 77 | identifier: 'AND', 78 | }, 79 | { 80 | name: 'OR', 81 | identifier: 'OR', 82 | }, 83 | ], 84 | rules: [ 85 | { 86 | identifier: 'txt', 87 | name: 'Text Selection', 88 | component: Component, 89 | initialValue: '', 90 | }, 91 | { 92 | identifier: 'num', 93 | name: 'Number Selection', 94 | component: Component, 95 | initialValue: 10, 96 | }, 97 | ], 98 | dragging: { 99 | animation: 300, 100 | disabled: false, 101 | ghostClass: 'ghost', 102 | }, 103 | }; 104 | 105 | it('ensures missing drag\'n\'drop configuration does not break the component', () => { 106 | const newConfig: QueryBuilderConfig = { 107 | operators: config.operators, 108 | rules: config.rules, 109 | }; 110 | 111 | mount(QueryBuilder, { 112 | propsData: { 113 | value, 114 | config: newConfig, 115 | }, 116 | }); 117 | }); 118 | 119 | it('tests drag\'n\'dropping within the same group', () => { 120 | const app = mount(QueryBuilder, { 121 | propsData: { 122 | value, 123 | config, 124 | }, 125 | }); 126 | 127 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value)); 128 | const { children } = (qbClone.children[0] as RuleSet).children[3] as RuleSet; 129 | children.splice(2, 0, children.splice(0, 1)[0]); 130 | ((qbClone.children[0] as RuleSet).children[3] as RuleSet).children = children; 131 | 132 | const group = app.findAllComponents(QueryBuilderGroup) 133 | .filter(qb => { 134 | const vm = qb.vm as QueryBuilderGroupInterface; 135 | 136 | return vm.selectedOperator === 'AND' 137 | && vm.children.length === 3 138 | && (vm.children[0] as Rule).value === 'D'; 139 | }) 140 | .at(0); 141 | 142 | const mvEv: ChangeEvent = { 143 | moved: { 144 | element: (group.vm as QueryBuilderGroupInterface).children[0], 145 | oldIndex: 0, 146 | newIndex: 2, 147 | }, 148 | }; 149 | group.findComponent(Draggable).vm.$emit('change', mvEv); 150 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'AND', children }); 151 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone); 152 | }); 153 | 154 | it('tests drag\'n\'drop by merging with a parent group', done => { 155 | const app = mount(QueryBuilder, { 156 | propsData: { 157 | value, 158 | config, 159 | }, 160 | }); 161 | 162 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value)); 163 | const remover = qbClone.children[0] as RuleSet; 164 | const adder = qbClone.children[1] as RuleSet; 165 | const element = remover.children.splice(1, 1)[0] as Rule; // Moving element 166 | adder.children.splice(3, 0, element); 167 | 168 | // Component we'd need to assert against 169 | const parent = app.findComponent(QueryBuilderGroup); 170 | 171 | // Removing branch 172 | const removerComponent = app.findAllComponents(QueryBuilderGroup) 173 | .filter(qb => { 174 | const vm = qb.vm as QueryBuilderGroupInterface; 175 | 176 | return vm.selectedOperator === 'AND' 177 | && vm.children.length === 4 178 | && (vm.children[0] as Rule).value === 'A'; 179 | }) 180 | .at(0); 181 | const rmEv: ChangeEvent = { 182 | removed: { 183 | element, 184 | oldIndex: 1, 185 | }, 186 | }; 187 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv); 188 | 189 | // Adding branch 190 | const adderComponent = app.findAllComponents(QueryBuilderGroup) 191 | .filter(qb => { 192 | const vm = qb.vm as QueryBuilderGroupInterface; 193 | 194 | return vm.selectedOperator === 'AND' 195 | && vm.children.length === 4 196 | && (vm.children[0] as Rule).value === 'X'; 197 | }) 198 | .at(0); 199 | const addEv: ChangeEvent = { 200 | added: { 201 | element, 202 | newIndex: 3, 203 | }, 204 | }; 205 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv); 206 | 207 | flushPromises() 208 | .then(() => { 209 | expect((parent.emitted('query-update') as any)[0][0]).toStrictEqual(qbClone); 210 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone); 211 | 212 | done(); 213 | }); 214 | }); 215 | 216 | it('tests drag\'n\'dropping with merging within the adding group', done => { 217 | const app = mount(QueryBuilder, { 218 | propsData: { 219 | value, 220 | config, 221 | }, 222 | }); 223 | 224 | // Move 'D' to parent group after 'C' 225 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value)); 226 | const adder = qbClone.children[0] as RuleSet; 227 | const remover = adder.children[3] as RuleSet; 228 | const element = remover.children.splice(0, 1)[0] as Rule; // Moving element 229 | adder.children.splice(3, 0, element); 230 | 231 | // Removing branch 232 | const removerComponent = app.findAllComponents(QueryBuilderGroup) 233 | .filter(qb => { 234 | const vm = qb.vm as QueryBuilderGroupInterface; 235 | 236 | return vm.selectedOperator === 'AND' 237 | && vm.children.length === 3 238 | && (vm.children[0] as Rule).value === 'D'; 239 | }) 240 | .at(0); 241 | const rmEv: ChangeEvent = { 242 | removed: { 243 | element, 244 | oldIndex: 0, 245 | }, 246 | }; 247 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv); 248 | 249 | // Adding branch 250 | const adderComponent = app.findAllComponents(QueryBuilderGroup) 251 | .filter(qb => { 252 | const vm = qb.vm as QueryBuilderGroupInterface; 253 | 254 | return vm.selectedOperator === 'AND' 255 | && vm.children.length === 4 256 | && (vm.children[0] as Rule).value === 'A'; 257 | }) 258 | .at(0); 259 | const addEv: ChangeEvent = { 260 | added: { 261 | element, 262 | newIndex: 3, 263 | }, 264 | }; 265 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv); 266 | 267 | flushPromises() 268 | .then(() => { 269 | expect((adderComponent.emitted('query-update') as any)[0][0]).toStrictEqual(adder); 270 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone); 271 | 272 | done(); 273 | }); 274 | }); 275 | 276 | it('tests drag\'n\'dropping with merging within the deleting group', done => { 277 | const app = mount(QueryBuilder, { 278 | propsData: { 279 | value, 280 | config, 281 | }, 282 | }); 283 | 284 | // Move 'A' to child group between 'D' and 'E' 285 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value)); 286 | const remover = qbClone.children[0] as RuleSet; 287 | const adder = remover.children[3] as RuleSet; 288 | const element = remover.children.splice(0, 1)[0] as Rule; // Moving element 289 | adder.children.splice(1, 0, element); 290 | 291 | // Removing branch 292 | const removerComponent = app.findAllComponents(QueryBuilderGroup) 293 | .filter(qb => { 294 | const vm = qb.vm as QueryBuilderGroupInterface; 295 | 296 | return vm.selectedOperator === 'AND' 297 | && vm.children.length === 4 298 | && (vm.children[0] as Rule).value === 'A'; 299 | }) 300 | .at(0); 301 | const rmEv: ChangeEvent = { 302 | removed: { 303 | element, 304 | oldIndex: 0, 305 | }, 306 | }; 307 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv); 308 | 309 | // Adding branch 310 | const adderComponent = app.findAllComponents(QueryBuilderGroup) 311 | .filter(qb => { 312 | const vm = qb.vm as QueryBuilderGroupInterface; 313 | 314 | return vm.selectedOperator === 'AND' 315 | && vm.children.length === 3 316 | && (vm.children[0] as Rule).value === 'D'; 317 | }) 318 | .at(0); 319 | const addEv: ChangeEvent = { 320 | added: { 321 | element, 322 | newIndex: 1, 323 | }, 324 | }; 325 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv); 326 | 327 | flushPromises() 328 | .then(() => { 329 | expect((removerComponent.emitted('query-update') as any)[0][0]).toStrictEqual(remover); 330 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone); 331 | 332 | done(); 333 | }); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /tests/unit/max-depth.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount, Wrapper } from '@vue/test-utils'; 2 | import Vue from 'vue'; 3 | import Sortable, { 4 | GroupOptions, PutResult, SortableEvent, SortableOptions, 5 | } from 'sortablejs'; 6 | import QueryBuilder from '@/QueryBuilder.vue'; 7 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue'; 8 | import QueryBuilderChild from '@/QueryBuilderChild.vue'; 9 | import { 10 | RuleSet, QueryBuilderConfig, Rule, GroupCtrlSlotProps, 11 | } from '@/types'; 12 | import Component from '../components/Component.vue'; 13 | 14 | interface QueryBuilderGroupInterface extends Vue { 15 | depth: number, 16 | maxDepthExeeded: boolean, 17 | dragOptions: SortableOptions, 18 | groupControlSlotProps: GroupCtrlSlotProps, 19 | } 20 | 21 | interface GroupOptionsInterface extends GroupOptions { 22 | put: ((to: Sortable, from: Sortable, dragEl: HTMLElement, event: SortableEvent) => PutResult) 23 | } 24 | 25 | interface DragOptionsInstance extends SortableOptions { 26 | group: GroupOptionsInterface, 27 | } 28 | 29 | describe('Testing max-depth behaviour', () => { 30 | const value: RuleSet = { 31 | operatorIdentifier: 'OR', 32 | children: [{ 33 | operatorIdentifier: 'AND', 34 | children: [{ 35 | identifier: 'txt', 36 | value: 'A', 37 | }, { 38 | identifier: 'txt', 39 | value: 'B', 40 | }, { 41 | identifier: 'txt', 42 | value: 'C', 43 | }, { 44 | operatorIdentifier: 'AND', 45 | children: [{ 46 | identifier: 'txt', 47 | value: 'D', 48 | }, { 49 | identifier: 'txt', 50 | value: 'E', 51 | }, { 52 | operatorIdentifier: 'AND', 53 | children: [{ 54 | identifier: 'txt', 55 | value: 'F', 56 | }, { 57 | operatorIdentifier: 'AND', 58 | children: [{ 59 | identifier: 'txt', 60 | value: 'G', 61 | }], 62 | }, { 63 | identifier: 'txt', 64 | value: 'H', 65 | }], 66 | }], 67 | }], 68 | }, { 69 | operatorIdentifier: 'AND', 70 | children: [{ 71 | identifier: 'txt', 72 | value: 'X', 73 | }, { 74 | operatorIdentifier: 'AND', 75 | children: [{ 76 | identifier: 'txt', 77 | value: 'T', 78 | }], 79 | }, { 80 | identifier: 'txt', 81 | value: 'Y', 82 | }, { 83 | identifier: 'txt', 84 | value: 'Z', 85 | }], 86 | }], 87 | }; 88 | 89 | const config: QueryBuilderConfig = { 90 | operators: [ 91 | { 92 | name: 'AND', 93 | identifier: 'AND', 94 | }, 95 | { 96 | name: 'OR', 97 | identifier: 'OR', 98 | }, 99 | ], 100 | rules: [ 101 | { 102 | identifier: 'txt', 103 | name: 'Text Selection', 104 | component: Component, 105 | initialValue: '', 106 | }, 107 | { 108 | identifier: 'num', 109 | name: 'Number Selection', 110 | component: Component, 111 | initialValue: 10, 112 | }, 113 | ], 114 | dragging: { 115 | animation: 300, 116 | disabled: false, 117 | ghostClass: 'ghost', 118 | }, 119 | maxDepth: 4, 120 | }; 121 | 122 | it('verifies not groups are added upon max-depth', () => { 123 | const createApp = () => mount(QueryBuilder, { 124 | propsData: { 125 | value: { ...value }, 126 | config: { ...config }, 127 | }, 128 | }); 129 | 130 | // Test non-leave group 131 | let app = createApp(); 132 | let group = app.findAllComponents(QueryBuilderGroup) 133 | .wrappers 134 | .filter(g => g.vm.$props.depth === 3) 135 | .shift() as Wrapper; 136 | // Assert button is present 137 | let button = group.find('.query-builder-group__group-adding-button'); 138 | expect(button.exists()).toBeTruthy(); 139 | button.trigger('click'); 140 | let evQueryUpdate = app.emitted('input'); 141 | expect(evQueryUpdate).toHaveLength(1); 142 | 143 | // Test "leaf" group 144 | app = createApp(); 145 | group = app.findAllComponents(QueryBuilderGroup) 146 | .wrappers 147 | .filter(g => g.vm.$props.depth === 4) 148 | .shift() as Wrapper; 149 | // Assert button is absent 150 | button = group.find('.query-builder-group__group-adding-button'); 151 | expect(button.exists()).toBeFalsy(); 152 | evQueryUpdate = app.emitted('input'); 153 | expect(evQueryUpdate).toBeUndefined(); 154 | }); 155 | 156 | it('verifies the behaviour of GroupCtrlSlotProps slot', () => { 157 | const createApp = () => mount(QueryBuilder, { 158 | propsData: { 159 | value: { ...value }, 160 | config: { ...config }, 161 | }, 162 | scopedSlots: { 163 | groupControl: ` 164 |
168 | SLOT 169 | 177 | 183 | 190 |
191 | `, 192 | }, 193 | }); 194 | 195 | let app = createApp(); 196 | let group = app.findAllComponents(QueryBuilderGroup) 197 | .wrappers 198 | .filter(g => g.vm.$props.depth === 3) 199 | .shift() as Wrapper; 200 | expect(group.vm.groupControlSlotProps.maxDepthExeeded).toBeFalsy(); 201 | // Assert button is present 202 | let button = group.find('.slot-new-group'); 203 | expect(button.exists()).toBeTruthy(); 204 | button.trigger('click'); 205 | let evQueryUpdate = app.emitted('input'); 206 | expect(evQueryUpdate).toHaveLength(1); 207 | 208 | // Test leaf group 209 | app = createApp(); 210 | group = app.findAllComponents(QueryBuilderGroup) 211 | .wrappers 212 | .filter(g => g.vm.$props.depth === 4) 213 | .shift() as Wrapper; 214 | expect(group.vm.groupControlSlotProps.maxDepthExeeded).toBeTruthy(); 215 | // Assert button is absent 216 | button = group.find('.slot-new-group'); 217 | expect(button.exists()).toBeFalsy(); 218 | evQueryUpdate = app.emitted('input'); 219 | expect(evQueryUpdate).toBeUndefined(); 220 | }); 221 | 222 | it('prunes existing branches which are beyond the max-depth setting', async () => { 223 | const app = mount(QueryBuilder, { 224 | propsData: { 225 | value: { ...value }, 226 | config: { ...config }, 227 | }, 228 | }); 229 | 230 | const wrapper = app.findComponent(QueryBuilder); 231 | 232 | // Before, ensure nothing has been changed 233 | expect(wrapper.vm.$props.value).toHaveProperty('children.0.children.3.children.2.children.1.children.0.value', 'G'); 234 | expect(app.emitted('input')).toBeUndefined(); 235 | 236 | // Reduce max depth 237 | await app.setProps({ 238 | value: { ...value }, 239 | config: { ...config, maxDepth: 3 }, 240 | }); 241 | expect(app.emitted('input')).toHaveLength(1); 242 | expect((app.emitted('input') as any[])[0]).not.toHaveProperty('0.children.0.children.3.children.2.children.1.children.0.value', 'G'); 243 | expect((app.emitted('input') as any[])[0][0].children[0].children[3].children[2].children).toHaveLength(2); 244 | expect((app.emitted('input') as any[])[0]).toHaveProperty('0.children.0.children.3.children.2.children', [{ identifier: 'txt', value: 'F' }, { identifier: 'txt', value: 'H' }]); 245 | 246 | // Don't allow any group children 247 | await app.setProps({ 248 | value: { ...value }, 249 | config: { ...config, maxDepth: 0 }, 250 | }); 251 | 252 | expect((app.emitted('input') as any[]).pop()[0].children).toHaveLength(0); 253 | }); 254 | 255 | it('asserts no additional group can be created, beyond the mad-depth setting', () => { 256 | const app = mount(QueryBuilder, { 257 | propsData: { 258 | value: { ...value }, 259 | config: { ...config }, 260 | }, 261 | }); 262 | 263 | const groups = app.findAllComponents(QueryBuilderGroup).wrappers; 264 | 265 | const group1 = ( 266 | groups.filter(g => g.vm.$props.depth === 1) 267 | .shift() 268 | ) as Wrapper; 269 | expect((group1.vm as QueryBuilderGroupInterface).maxDepthExeeded).toBeFalsy(); 270 | expect(group1.find('.query-builder-group__group-adding-button').exists()).toBeTruthy(); 271 | 272 | const group4 = ( 273 | groups.filter(g => g.vm.$props.depth === 4) 274 | .shift() 275 | ) as Wrapper; 276 | expect((group4.vm as QueryBuilderGroupInterface).maxDepthExeeded).toBeTruthy(); 277 | expect(group4.find('.query-builder-group__group-adding-button').exists()).toBeFalsy(); 278 | }); 279 | 280 | it('checks and rejects movements, violating the max depth policy', () => { 281 | const buildDragEl = (r: Rule | RuleSet, c: QueryBuilderConfig): HTMLElement => { 282 | const rChild = shallowMount(QueryBuilderChild, { 283 | propsData: { 284 | query: { ...r }, 285 | config: { ...c }, 286 | }, 287 | }); 288 | 289 | const rEl = { 290 | __vue__: rChild.vm, 291 | } as unknown; 292 | 293 | return rEl as HTMLElement; 294 | }; 295 | 296 | const buildDragOptions = (ws: Array>): DragOptionsInstance => { 297 | const w = ws.shift() as Wrapper; 298 | const qbgi = w.vm as QueryBuilderGroupInterface; 299 | 300 | return (qbgi.dragOptions as DragOptionsInstance); 301 | }; 302 | 303 | const app = mount(QueryBuilder, { 304 | propsData: { 305 | value: { ...value }, 306 | config: { ...config }, 307 | }, 308 | }); 309 | 310 | const groups = app.findAllComponents(QueryBuilderGroup).wrappers; 311 | const s = (null as never) as Sortable; 312 | const se = (null as never) as SortableEvent; 313 | 314 | // Moving rule is always fine 315 | const movingRule = (value as any) 316 | .children[0].children[3].children[2].children[1].children[0] as Rule | RuleSet; 317 | 318 | const dragOptions1 = buildDragOptions(groups.filter(g => g.vm.$props.depth === 1)); 319 | expect(dragOptions1.group.put(s, s, buildDragEl(movingRule, config), se)).toBeTruthy(); 320 | 321 | const dragOptions2 = buildDragOptions(groups.filter(g => g.vm.$props.depth === 4)); 322 | expect(dragOptions2.group.put(s, s, buildDragEl(movingRule, config), se)).toBeTruthy(); 323 | 324 | // Moving ruleset needs extra check 325 | const movingRuleSet = (value as any).children[1] as Rule | RuleSet; 326 | expect(dragOptions1.group.put(s, s, buildDragEl(movingRuleSet, config), se)).toBeTruthy(); 327 | expect(dragOptions2.group.put(s, s, buildDragEl(movingRuleSet, config), se)).toBeFalsy(); 328 | }); 329 | 330 | it('checks and verifies GroupCtrlSlot\'s props behaviour', async () => { 331 | const newRuleSet = (): RuleSet => ({ 332 | operatorIdentifier: 'AND', 333 | children: [], 334 | }); 335 | 336 | const getMergeTrap = jest.fn(); 337 | 338 | const app = shallowMount(QueryBuilderGroup, { 339 | propsData: { 340 | config: { ...config }, 341 | query: newRuleSet(), 342 | depth: 4, 343 | }, 344 | provide: { 345 | getMergeTrap, 346 | }, 347 | }) as Wrapper; 348 | 349 | const slotPropsLeafGroup = app.vm.groupControlSlotProps; 350 | expect(slotPropsLeafGroup.maxDepthExeeded).toBeTruthy(); 351 | slotPropsLeafGroup.newGroup(); 352 | expect(app.emitted('query-update')).toBeUndefined(); 353 | 354 | // Now try adding another by stepping down 1 depth 355 | app.setProps({ 356 | config: { ...config }, 357 | query: newRuleSet(), 358 | depth: 3, 359 | }); 360 | await app.vm.$nextTick(); 361 | 362 | const slotPropsNonLeafGroup = app.vm.groupControlSlotProps; 363 | expect(slotPropsNonLeafGroup.maxDepthExeeded).toBeFalsy(); 364 | slotPropsNonLeafGroup.newGroup(); 365 | expect(app.emitted('query-update')).toHaveLength(1); 366 | 367 | expect(getMergeTrap).not.toBeCalled(); 368 | }); 369 | }); 370 | -------------------------------------------------------------------------------- /tests/unit/query-builder-child.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import Vue, { Component as VueComponent } from 'vue'; 3 | import QueryBuilderChild from '@/QueryBuilderChild.vue'; 4 | import { 5 | QueryBuilderConfig, Rule, RuleDefinition, RuleSet, 6 | } from '@/types'; 7 | import Component from '../components/Component.vue'; 8 | 9 | interface QueryBuilderChildInterface extends Vue { 10 | component: VueComponent, 11 | definition: RuleDefinition | null, 12 | ruleDefinition: RuleDefinition | null, 13 | } 14 | 15 | describe('Testing QueryBuilderChild', () => { 16 | const config: QueryBuilderConfig = { 17 | operators: [ 18 | { 19 | name: 'AND', 20 | identifier: 'AND', 21 | }, 22 | { 23 | name: 'OR', 24 | identifier: 'OR', 25 | }, 26 | ], 27 | rules: [ 28 | { 29 | identifier: 'txt', 30 | name: 'Text Selection', 31 | component: Component, 32 | initialValue: '', 33 | }, 34 | { 35 | identifier: 'num', 36 | name: 'Number Selection', 37 | component: Component, 38 | initialValue: 10, 39 | }, 40 | ], 41 | dragging: { 42 | animation: 300, 43 | disabled: false, 44 | ghostClass: 'ghost', 45 | }, 46 | }; 47 | 48 | it('tests if QueryBuilderChild handles rule query', () => { 49 | const query: Rule = { 50 | identifier: 'txt', 51 | value: 'A', 52 | }; 53 | 54 | const child = shallowMount(QueryBuilderChild, { 55 | propsData: { 56 | config: { ...config }, 57 | query: { ...query }, 58 | }, 59 | }); 60 | 61 | expect((child.vm as QueryBuilderChildInterface).component.name).toBe('QueryBuilderRule'); 62 | expect((child.vm as QueryBuilderChildInterface).definition).not.toBeNull(); 63 | expect((child.vm as QueryBuilderChildInterface).ruleDefinition).not.toBeNull(); 64 | }); 65 | 66 | it('tests if QueryBuilderChild handles group query', () => { 67 | const query: RuleSet = { 68 | operatorIdentifier: 'AND', 69 | children: [{ 70 | identifier: 'txt', 71 | value: 'X', 72 | }], 73 | }; 74 | 75 | const child = shallowMount(QueryBuilderChild, { 76 | propsData: { 77 | config: { ...config }, 78 | query: { ...query }, 79 | }, 80 | }); 81 | 82 | expect((child.vm as QueryBuilderChildInterface).component.name).toBe('QueryBuilderGroup'); 83 | expect((child.vm as QueryBuilderChildInterface).definition).toBeNull(); 84 | expect((child.vm as QueryBuilderChildInterface).ruleDefinition).toBeNull(); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/unit/query-builder.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import QueryBuilder from '@/QueryBuilder.vue'; 3 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue'; 4 | import QueryBuilderRule from '@/QueryBuilderRule.vue'; 5 | import { QueryBuilderConfig } from '@/types'; 6 | import App from '../components/App.vue'; 7 | import Component from '../components/Component.vue'; 8 | 9 | interface QueryBuilderTemplate { 10 | value: any, 11 | config: QueryBuilderConfig, 12 | } 13 | 14 | describe('Test basic functionality of QueryBuilder.vue', () => { 15 | const getTemplate = (): QueryBuilderTemplate => ({ 16 | value: null, 17 | config: { 18 | operators: [ 19 | { 20 | name: 'AND', 21 | identifier: 'and', 22 | }, 23 | { 24 | name: 'OR', 25 | identifier: 'or', 26 | }, 27 | ], 28 | rules: [ 29 | { 30 | identifier: 'txt', 31 | name: 'Text Selection', 32 | component: Component, 33 | initialValue: '', 34 | }, 35 | { 36 | identifier: 'num', 37 | name: 'Number Selection', 38 | component: Component, 39 | initialValue: 10, 40 | }, 41 | ], 42 | }, 43 | }); 44 | 45 | it('it renders with blank configuration', () => { 46 | const template = getTemplate(); 47 | 48 | template.config.operators = []; 49 | template.config.rules = []; 50 | 51 | // The bare minimum configuration. 52 | // It entirely useless, but according to the spec, this is a valid configuration and show not 53 | // fail. 54 | mount(QueryBuilder, { 55 | propsData: template, 56 | }); 57 | }); 58 | 59 | it('selects an operator', () => { 60 | const app = mount(App, { 61 | data: getTemplate, 62 | }); 63 | const wrapper = app.findComponent(QueryBuilder); 64 | 65 | // Assert operators are available 66 | const options = wrapper.find('.query-builder-group__group-selection select').findAll('option'); 67 | expect(options).toHaveLength(3); 68 | expect(options.at(0).text()).toBe('Select an operator'); 69 | expect(options.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy(); 70 | expect(options.at(1).text()).toBe('AND'); 71 | expect((options.at(1).element as HTMLOptionElement).value).toBe('and'); 72 | expect(options.at(2).text()).toBe('OR'); 73 | expect((options.at(2).element as HTMLOptionElement).value).toBe('or'); 74 | 75 | // Assert update has propagated 76 | options.at(2).setSelected(); 77 | expect(wrapper.emitted('input')).toHaveLength(1); 78 | expect(wrapper.emitted('input')).toStrictEqual([[{ operatorIdentifier: 'or', children: [] }]]); 79 | }); 80 | 81 | it('selects a rule', async () => { 82 | const app = mount(App, { 83 | data: getTemplate, 84 | }); 85 | const wrapper = app.findComponent(QueryBuilder); 86 | 87 | // Assert rules are available 88 | const rules = wrapper.find('.query-builder-group__group-control select').findAll('option'); 89 | expect(rules).toHaveLength(3); 90 | expect(rules.at(0).text()).toBe('Select a rule'); 91 | expect(rules.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy(); 92 | expect(rules.at(1).text()).toBe('Text Selection'); 93 | expect((rules.at(1).element as HTMLOptionElement).value).toBe('txt'); 94 | expect(rules.at(2).text()).toBe('Number Selection'); 95 | expect((rules.at(2).element as HTMLOptionElement).value).toBe('num'); 96 | 97 | const addRuleBtn = wrapper.find('.query-builder-group__rule-adding-button'); 98 | expect((addRuleBtn.element as HTMLButtonElement).disabled).toBeTruthy(); 99 | 100 | // Assert update has propagated with default value 101 | rules.at(2).setSelected(); 102 | await wrapper.vm.$nextTick(); 103 | expect((addRuleBtn.element as HTMLButtonElement).disabled).toBeFalsy(); 104 | addRuleBtn.trigger('click'); 105 | expect(wrapper.emitted('input')).toHaveLength(1); 106 | expect(wrapper.emitted('input')).toStrictEqual([[{ operatorIdentifier: 'and', children: [{ identifier: 'num', value: 10 }] }]]); 107 | 108 | // Manually update value 109 | await wrapper.vm.$nextTick(); 110 | const num = wrapper.findComponent(Component); 111 | num.vm.$emit('input', 20); 112 | expect(wrapper.emitted('input')).toHaveLength(2); 113 | expect((wrapper.emitted('input') as any)[1]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'num', value: 20 }] }]); 114 | }); 115 | 116 | it('makes use of an initial value\'s factory function', async () => { 117 | const initialValue = jest.fn(() => 'Hello World'); 118 | 119 | const data = getTemplate(); 120 | data.config.rules = [ 121 | { 122 | identifier: 'txt', 123 | name: 'Text Selection', 124 | component: Component, 125 | initialValue, 126 | }, 127 | ]; 128 | 129 | const app = mount(App, { 130 | data() { 131 | return { ...data }; 132 | }, 133 | }); 134 | const wrapper = app.findComponent(QueryBuilder); 135 | 136 | // Assert rules are available 137 | const group = wrapper.findComponent(QueryBuilderGroup); 138 | const rules = group.find('.query-builder-group__group-control select').findAll('option'); 139 | const addRuleBtn = group.find('.query-builder-group__rule-adding-button'); 140 | 141 | // Assert update has propagated with default value 142 | rules.at(1).setSelected(); 143 | await group.vm.$nextTick(); 144 | addRuleBtn.trigger('click'); 145 | expect(group.emitted('query-update')).toHaveLength(1); 146 | expect((group.emitted('query-update') as any)[0]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'txt', value: 'Hello World' }] }]); 147 | expect(wrapper.emitted('input')).toHaveLength(1); 148 | expect((wrapper.emitted('input') as any)[0]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'txt', value: 'Hello World' }] }]); 149 | expect(initialValue).toHaveBeenCalled(); 150 | }); 151 | 152 | it('deletes a rule', () => { 153 | const data = () => ({ 154 | query: { 155 | operatorIdentifier: 'and', 156 | children: [ 157 | { 158 | identifier: 'txt', 159 | value: 'A', 160 | }, 161 | { 162 | identifier: 'txt', 163 | value: 'B', 164 | }, 165 | { 166 | identifier: 'txt', 167 | value: 'C', 168 | }, 169 | ], 170 | }, 171 | config: { 172 | operators: [ 173 | { 174 | name: 'AND', 175 | identifier: 'and', 176 | }, 177 | { 178 | name: 'OR', 179 | identifier: 'or', 180 | }, 181 | ], 182 | rules: [ 183 | { 184 | identifier: 'txt', 185 | name: 'Text Selection', 186 | component: Component, 187 | initialValue: '', 188 | }, 189 | { 190 | identifier: 'num', 191 | name: 'Number Selection', 192 | component: Component, 193 | initialValue: 10, 194 | }, 195 | ], 196 | }, 197 | }); 198 | 199 | const app = mount(App, { 200 | data, 201 | }); 202 | 203 | app.findAllComponents(QueryBuilderRule) 204 | .filter(({ vm }) => vm.$props.query.identifier === 'txt' && vm.$props.query.value === 'B') 205 | .at(0) 206 | .vm 207 | .$parent 208 | .$emit('delete-child'); 209 | 210 | expect(app.vm.$data.query).toStrictEqual({ 211 | operatorIdentifier: 'and', 212 | children: [ 213 | { 214 | identifier: 'txt', 215 | value: 'A', 216 | }, 217 | { 218 | identifier: 'txt', 219 | value: 'C', 220 | }, 221 | ], 222 | }); 223 | }); 224 | 225 | it('renders a complex dataset', async () => { 226 | const data = () => ({ 227 | query: { 228 | operatorIdentifier: 'or', 229 | children: [ 230 | { 231 | operatorIdentifier: 'and', 232 | children: [ 233 | { 234 | identifier: 'txt', 235 | value: 'A', 236 | }, 237 | { 238 | identifier: 'txt', 239 | value: 'B', 240 | }, 241 | { 242 | identifier: 'txt', 243 | value: 'C', 244 | }, 245 | { 246 | operatorIdentifier: 'and', // <-- on this group, we're performing our tests 247 | children: [ 248 | { 249 | identifier: 'txt', 250 | value: 'c', 251 | }, 252 | { 253 | identifier: 'txt', 254 | value: 'd', 255 | }, 256 | { 257 | operatorIdentifier: 'and', 258 | children: [ 259 | { 260 | identifier: 'txt', 261 | value: 'a', 262 | }, 263 | { 264 | identifier: 'txt', 265 | value: 'b', 266 | }, 267 | ], 268 | }, 269 | ], 270 | }, 271 | ], 272 | }, 273 | { 274 | operatorIdentifier: 'and', 275 | children: [ 276 | { 277 | identifier: 'txt', 278 | value: 'X', 279 | }, 280 | { 281 | identifier: 'txt', 282 | value: 'Y', 283 | }, 284 | { 285 | identifier: 'txt', 286 | value: 'Z', 287 | }, 288 | ], 289 | }, 290 | ], 291 | }, 292 | config: { 293 | operators: [ 294 | { 295 | name: 'AND', 296 | identifier: 'and', 297 | }, 298 | { 299 | name: 'OR', 300 | identifier: 'or', 301 | }, 302 | ], 303 | rules: [ 304 | { 305 | identifier: 'txt', 306 | name: 'Text Selection', 307 | component: Component, 308 | initialValue: '', 309 | }, 310 | { 311 | identifier: 'num', 312 | name: 'Number Selection', 313 | component: Component, 314 | initialValue: 10, 315 | }, 316 | ], 317 | }, 318 | }); 319 | 320 | const app = mount(App, { 321 | data, 322 | }); 323 | const wrapper = app.findComponent(QueryBuilder); 324 | 325 | const qbGroup = wrapper.findAllComponents(QueryBuilderGroup) 326 | .filter( 327 | ({ vm }) => (vm as (QueryBuilderGroup & { readonly selectedOperator: string })).selectedOperator === 'and' 328 | && vm.$props.query.children.length === 3 329 | && vm.$props.query.children[0].identifier === 'txt' 330 | && vm.$props.query.children[0].value === 'c', 331 | ) 332 | .at(0); 333 | 334 | // Assert operators are available 335 | const options = qbGroup.find('.query-builder-group__group-selection select').findAll('option'); 336 | expect(options).toHaveLength(3); 337 | expect(options.at(0).text()).toBe('Select an operator'); 338 | expect(options.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy(); 339 | expect(options.at(1).text()).toBe('AND'); 340 | expect((options.at(1).element as HTMLOptionElement).value).toBe('and'); 341 | expect(options.at(2).text()).toBe('OR'); 342 | expect((options.at(2).element as HTMLOptionElement).value).toBe('or'); 343 | 344 | // Assert update has propagated 345 | options.at(2).setSelected(); 346 | expect(app.vm.$data.query).toStrictEqual({ 347 | operatorIdentifier: 'or', 348 | children: [ 349 | { 350 | operatorIdentifier: 'and', 351 | children: [ 352 | { 353 | identifier: 'txt', 354 | value: 'A', 355 | }, 356 | { 357 | identifier: 'txt', 358 | value: 'B', 359 | }, 360 | { 361 | identifier: 'txt', 362 | value: 'C', 363 | }, 364 | { 365 | operatorIdentifier: 'or', // <-- changed 366 | children: [ 367 | { 368 | identifier: 'txt', 369 | value: 'c', 370 | }, 371 | { 372 | identifier: 'txt', 373 | value: 'd', 374 | }, 375 | { 376 | operatorIdentifier: 'and', 377 | children: [ 378 | { 379 | identifier: 'txt', 380 | value: 'a', 381 | }, 382 | { 383 | identifier: 'txt', 384 | value: 'b', 385 | }, 386 | ], 387 | }, 388 | ], 389 | }, 390 | ], 391 | }, 392 | { 393 | operatorIdentifier: 'and', 394 | children: [ 395 | { 396 | identifier: 'txt', 397 | value: 'X', 398 | }, 399 | { 400 | identifier: 'txt', 401 | value: 'Y', 402 | }, 403 | { 404 | identifier: 'txt', 405 | value: 'Z', 406 | }, 407 | ], 408 | }, 409 | ], 410 | }); 411 | 412 | // Edit a rule 413 | expect(qbGroup.vm.$props.query.children).toHaveLength(3); 414 | const rules = qbGroup.findAllComponents(QueryBuilderRule); 415 | expect(rules).toHaveLength(4); 416 | const rule = rules 417 | .filter(({ vm: { $props } }) => $props.query.identifier === 'txt' && $props.query.value === 'd') 418 | .at(0); 419 | 420 | await wrapper.vm.$nextTick(); 421 | rule.find('.dummy-component').vm.$emit('input', 'D'); 422 | expect(app.vm.$data.query).toStrictEqual({ 423 | operatorIdentifier: 'or', 424 | children: [ 425 | { 426 | operatorIdentifier: 'and', 427 | children: [ 428 | { 429 | identifier: 'txt', 430 | value: 'A', 431 | }, 432 | { 433 | identifier: 'txt', 434 | value: 'B', 435 | }, 436 | { 437 | identifier: 'txt', 438 | value: 'C', 439 | }, 440 | { 441 | operatorIdentifier: 'or', 442 | children: [ 443 | { 444 | identifier: 'txt', 445 | value: 'c', 446 | }, 447 | { 448 | identifier: 'txt', 449 | value: 'D', // <-- changed 450 | }, 451 | { 452 | operatorIdentifier: 'and', 453 | children: [ 454 | { 455 | identifier: 'txt', 456 | value: 'a', 457 | }, 458 | { 459 | identifier: 'txt', 460 | value: 'b', 461 | }, 462 | ], 463 | }, 464 | ], 465 | }, 466 | ], 467 | }, 468 | { 469 | operatorIdentifier: 'and', 470 | children: [ 471 | { 472 | identifier: 'txt', 473 | value: 'X', 474 | }, 475 | { 476 | identifier: 'txt', 477 | value: 'Y', 478 | }, 479 | { 480 | identifier: 'txt', 481 | value: 'Z', 482 | }, 483 | ], 484 | }, 485 | ], 486 | }); 487 | 488 | // Add another group 489 | await wrapper.vm.$nextTick(); 490 | qbGroup.find('.query-builder-group__group-adding-button') 491 | .trigger('click'); 492 | expect(app.vm.$data.query).toStrictEqual({ 493 | operatorIdentifier: 'or', 494 | children: [ 495 | { 496 | operatorIdentifier: 'and', 497 | children: [ 498 | { 499 | identifier: 'txt', 500 | value: 'A', 501 | }, 502 | { 503 | identifier: 'txt', 504 | value: 'B', 505 | }, 506 | { 507 | identifier: 'txt', 508 | value: 'C', 509 | }, 510 | { 511 | operatorIdentifier: 'or', 512 | children: [ 513 | { 514 | identifier: 'txt', 515 | value: 'c', 516 | }, 517 | { 518 | identifier: 'txt', 519 | value: 'D', 520 | }, 521 | { 522 | operatorIdentifier: 'and', 523 | children: [ 524 | { 525 | identifier: 'txt', 526 | value: 'a', 527 | }, 528 | { 529 | identifier: 'txt', 530 | value: 'b', 531 | }, 532 | ], 533 | }, 534 | { // <-- Added 535 | operatorIdentifier: 'and', 536 | children: [], 537 | }, 538 | ], 539 | }, 540 | ], 541 | }, 542 | { 543 | operatorIdentifier: 'and', 544 | children: [ 545 | { 546 | identifier: 'txt', 547 | value: 'X', 548 | }, 549 | { 550 | identifier: 'txt', 551 | value: 'Y', 552 | }, 553 | { 554 | identifier: 'txt', 555 | value: 'Z', 556 | }, 557 | ], 558 | }, 559 | ], 560 | }); 561 | 562 | // Remove a rule 563 | await wrapper.vm.$nextTick(); 564 | rule.vm.$parent.$emit('delete-child'); 565 | expect(app.vm.$data.query).toStrictEqual({ 566 | operatorIdentifier: 'or', 567 | children: [ 568 | { 569 | operatorIdentifier: 'and', 570 | children: [ 571 | { 572 | identifier: 'txt', 573 | value: 'A', 574 | }, 575 | { 576 | identifier: 'txt', 577 | value: 'B', 578 | }, 579 | { 580 | identifier: 'txt', 581 | value: 'C', 582 | }, 583 | { 584 | operatorIdentifier: 'or', 585 | children: [ 586 | { 587 | identifier: 'txt', 588 | value: 'c', 589 | }, 590 | // Delete child here 591 | { 592 | operatorIdentifier: 'and', 593 | children: [ 594 | { 595 | identifier: 'txt', 596 | value: 'a', 597 | }, 598 | { 599 | identifier: 'txt', 600 | value: 'b', 601 | }, 602 | ], 603 | }, 604 | { // <-- Added 605 | operatorIdentifier: 'and', 606 | children: [], 607 | }, 608 | ], 609 | }, 610 | ], 611 | }, 612 | { 613 | operatorIdentifier: 'and', 614 | children: [ 615 | { 616 | identifier: 'txt', 617 | value: 'X', 618 | }, 619 | { 620 | identifier: 'txt', 621 | value: 'Y', 622 | }, 623 | { 624 | identifier: 'txt', 625 | value: 'Z', 626 | }, 627 | ], 628 | }, 629 | ], 630 | }); 631 | }); 632 | }); 633 | -------------------------------------------------------------------------------- /tests/unit/slots.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { RuleSet, Rule, QueryBuilderConfig } from '@/types'; 3 | import QueryBuilder from '@/QueryBuilder.vue'; 4 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue'; 5 | import QueryBuilderRule from '@/QueryBuilderRule.vue'; 6 | import Component from '../components/Component.vue'; 7 | 8 | describe('Testing slot related features', () => { 9 | const propsData: { value: RuleSet, config: QueryBuilderConfig } = { 10 | value: { 11 | operatorIdentifier: 'OR', 12 | children: [ 13 | { 14 | identifier: 'txt', 15 | value: 'A', 16 | }, 17 | { 18 | identifier: 'txt', 19 | value: 'B', 20 | }, 21 | { 22 | identifier: 'txt', 23 | value: 'C', 24 | }, 25 | ], 26 | }, 27 | config: { 28 | operators: [ 29 | { 30 | name: 'and', 31 | identifier: 'AND', 32 | }, 33 | { 34 | name: 'or', 35 | identifier: 'OR', 36 | }, 37 | ], 38 | rules: [ 39 | { 40 | identifier: 'txt', 41 | name: 'Text Selection', 42 | component: Component, 43 | initialValue: 'foo', 44 | }, 45 | { 46 | identifier: 'num', 47 | name: 'Number Selection', 48 | component: Component, 49 | initialValue: 10, 50 | }, 51 | ], 52 | }, 53 | }; 54 | 55 | it('tests the `groupOperator` slot', () => { 56 | const app = mount(QueryBuilder, { 57 | propsData, 58 | scopedSlots: { 59 | groupOperator: ` 60 |
64 | SLOT Operator 65 | 77 |
78 | `, 79 | }, 80 | }); 81 | 82 | const group = app.findComponent(QueryBuilderGroup); 83 | const slot = group.find('.slot-wrapper'); 84 | 85 | // Check if current operator is selected 86 | const select = slot.find('.slot-select'); 87 | expect((select.element as HTMLSelectElement).value).toBe('OR'); 88 | 89 | // Check if operators are properly rendered 90 | const options = select.findAll('option'); 91 | expect(options).toHaveLength(2); 92 | expect((options.at(0).element as HTMLOptionElement).value).toBe('AND'); 93 | expect((options.at(0).element as HTMLOptionElement).text).toBe('and'); 94 | expect((options.at(1).element as HTMLOptionElement).value).toBe('OR'); 95 | expect((options.at(1).element as HTMLOptionElement).text).toBe('or'); 96 | 97 | // Update operator 98 | options.at(0).setSelected(); 99 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'AND', children: propsData.value.children }); 100 | const expected: RuleSet = JSON.parse(JSON.stringify(propsData.value)); 101 | expected.operatorIdentifier = 'AND'; 102 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(expected); 103 | }); 104 | 105 | it('tests the `groupControl` slot', async () => { 106 | const app = mount(QueryBuilder, { 107 | propsData, 108 | scopedSlots: { 109 | groupControl: ` 110 |
114 | SLOT 115 | 123 | 129 | 135 |
136 | `, 137 | }, 138 | }); 139 | 140 | const slot = app.find('.slot-wrapper'); 141 | const group = app.findComponent(QueryBuilderGroup); 142 | 143 | // Some data we'll be using for our assertions 144 | const query: RuleSet = JSON.parse(JSON.stringify(propsData.value)); 145 | const children = [...propsData.value.children]; 146 | 147 | // check if rules are properly rendered 148 | const options = slot.findAll('option'); 149 | expect(options).toHaveLength(2); 150 | expect((options.at(0).element as HTMLOptionElement).value).toBe('txt'); 151 | expect((options.at(0).element as HTMLOptionElement).text).toBe('Text Selection'); 152 | expect((options.at(1).element as HTMLOptionElement).value).toBe('num'); 153 | expect((options.at(1).element as HTMLOptionElement).text).toBe('Number Selection'); 154 | 155 | // Add a new rule 156 | slot.find('.slot-new-rule').trigger('click'); 157 | children.push({ identifier: 'txt', value: 'foo' }); 158 | query.children = [...children]; 159 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'OR', children }); 160 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(query); 161 | 162 | // Add new group 163 | app.setProps({ value: { ...query }, config: { ...propsData.config } }); 164 | await app.vm.$nextTick(); 165 | slot.find('.slot-new-group').trigger('click'); 166 | children.push({ operatorIdentifier: 'AND', children: [] }); 167 | query.children = [...children]; 168 | expect((group.emitted('query-update') as any)[1][0]).toStrictEqual({ operatorIdentifier: 'OR', children }); 169 | expect((app.emitted('input') as any)[1][0]).toStrictEqual(query); 170 | }); 171 | 172 | it('tests the `rule` slot', () => { 173 | const app = mount(QueryBuilder, { 174 | propsData, 175 | scopedSlots: { 176 | rule: ` 177 |
181 | SLOT 182 | 189 |
190 | `, 191 | }, 192 | }); 193 | 194 | const rule = app.findComponent(QueryBuilderRule); 195 | const slot = rule.find('.slot-wrapper'); 196 | const ruleComponent = slot.find('.slot-rule'); 197 | 198 | // Verify rule slot is properly rendered 199 | expect(ruleComponent.is(Component)).toBeTruthy(); 200 | expect(ruleComponent.vm.$props.value).toBe('A'); 201 | expect(rule.vm.$props.query.identifier).toBe('txt'); 202 | expect(ruleComponent.vm.$props.identifier).toBe('txt'); 203 | ruleComponent.vm.$emit('input', 'a'); 204 | expect((rule.emitted('query-update') as any)[0][0]).toStrictEqual({ identifier: 'txt', value: 'a' }); 205 | 206 | // Verify update event propagates 207 | const expected: RuleSet = JSON.parse(JSON.stringify(propsData.value)); 208 | (expected.children[0] as Rule).value = 'a'; 209 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(expected); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /tests/unit/validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isQueryBuilderConfig, isRule, isRuleSet, isOperatorDefinition, isRuleDefinition, 3 | } from '@/guards'; 4 | import Component from '../components/Component.vue'; 5 | 6 | describe('Testing component props and guards', () => { 7 | it('checks isRule guard', () => { 8 | // VALID 9 | [ 10 | { 11 | identifier: 'foo', 12 | value: null, 13 | }, 14 | ].forEach(t => expect(isRule(t)).toBeTruthy()); 15 | expect((t: any) => expect(isRule(t)).toBeTruthy()); 16 | 17 | // INVALID 18 | [ 19 | null, 20 | {}, 21 | 'xyz', 22 | { value: null }, 23 | { identifier: 'foo' }, 24 | { identifier: 123, value: null }, 25 | ].forEach((t: any) => expect(isRule(t)).toBeFalsy()); 26 | }); 27 | 28 | it('checks isRuleSet guard', () => { 29 | // VALID 30 | [ 31 | { 32 | operatorIdentifier: 'bar', 33 | children: [], 34 | }, 35 | 36 | { 37 | operatorIdentifier: 'foo', 38 | children: [ 39 | { 40 | identifier: 'bar', 41 | value: null, 42 | }, 43 | { 44 | operatorIdentifier: 'baz', 45 | children: [], 46 | }, 47 | ], 48 | }, 49 | ].forEach(t => expect(isRuleSet(t)).toBeTruthy()); 50 | 51 | // INVALID 52 | [ 53 | null, 54 | {}, 55 | 'xyz', 56 | { operatorIdentifier: 'bar' }, 57 | { children: [] }, 58 | { operatorIdentifier: 'bar', children: null }, 59 | { operatorIdentifier: 'bar', children: {} }, 60 | { identifier: 123, value: null }, 61 | { operatorIdentifier: 'foo', children: [{ identifier: 123, value: null }, { operatorIdentifier: 'baz', children: [] }] }, 62 | { operatorIdentifier: 'foo', children: [{ identifier: 'bar', value: null }, { operatorIdentifier: 'baz', children: 'invalid' }] }, 63 | ].forEach((t: any) => expect(isRuleSet(t)).toBeFalsy()); 64 | }); 65 | 66 | it('checks isOperatorDefinition guards', () => { 67 | // VALID 68 | [ 69 | { 70 | identifier: 'foo', 71 | name: 'foo', 72 | }, 73 | ].forEach((t: any) => expect(isOperatorDefinition(t)).toBeTruthy()); 74 | 75 | // INVALID 76 | [ 77 | { name: 'bar' }, 78 | { identifier: 'bar' }, 79 | {}, 80 | [], 81 | null, 82 | ].forEach((t: any) => expect(isOperatorDefinition(t)).toBeFalsy()); 83 | }); 84 | 85 | it('checks isRuleDefinition guards', () => { 86 | // VALID 87 | [ 88 | { 89 | identifier: 'foo', 90 | name: 'foo', 91 | component: 'foo', 92 | initialValue: 'asdf', 93 | }, 94 | { 95 | identifier: 'bar', 96 | name: 'bar', 97 | component: () => {}, 98 | }, 99 | { 100 | identifier: 'baz', 101 | name: 'baz', 102 | component: Component, 103 | }, 104 | ].forEach((t: any) => expect(isRuleDefinition(t)).toBeTruthy()); 105 | 106 | // INVALID 107 | [ 108 | {}, 109 | null, 110 | [], 111 | { name: 'baz', component: Component }, 112 | { identifier: 'baz', component: Component }, 113 | { identifier: 'baz', name: 'baz', component: 1234 }, 114 | ].forEach((t: any) => expect(isRuleDefinition(t)).toBeFalsy()); 115 | }); 116 | 117 | it('checks isQueryBuilderConfig guard', () => { 118 | // VALID 119 | [ 120 | { // Default, minimal example with all valid combinations 121 | operators: [ 122 | { 123 | identifier: 'foo', 124 | name: 'foo', 125 | }, 126 | ], 127 | rules: [ 128 | { 129 | identifier: 'foo', 130 | name: 'foo', 131 | component: 'foo', 132 | }, 133 | { 134 | identifier: 'bar', 135 | name: 'bar', 136 | component: () => {}, 137 | }, 138 | { 139 | identifier: 'baz', 140 | name: 'baz', 141 | component: Component, 142 | }, 143 | ], 144 | }, 145 | 146 | { // Only checking for valid colors 147 | operators: [], 148 | rules: [], 149 | colors: ['foo', 'bar'], 150 | }, 151 | 152 | { // Only checking for maxDepth 153 | operators: [], 154 | rules: [], 155 | maxDepth: undefined, 156 | }, 157 | { 158 | operators: [], 159 | rules: [], 160 | maxDepth: 10, 161 | }, 162 | ].forEach((t: any) => expect(isQueryBuilderConfig(t)).toBeTruthy()); 163 | 164 | // INVALID 165 | [ 166 | null, // Check nulled parameter 167 | 168 | { // Invalid operator 169 | operators: [ 170 | { 171 | identifier: 'foo', 172 | }, 173 | ], 174 | rules: [], 175 | }, 176 | 177 | { // Invalid rule 178 | operators: [ 179 | ], 180 | rules: [ 181 | { 182 | identifier: 'foo', 183 | name: 'foo', 184 | component: 123, 185 | }, 186 | ], 187 | }, 188 | 189 | { // Invalid color 190 | operators: [], 191 | rules: [], 192 | colors: [ 193 | 123, 194 | ], 195 | }, 196 | 197 | { // Invalid maxDepth 198 | operators: [], 199 | rules: [], 200 | maxDepth: -1, 201 | }, 202 | { 203 | operators: [], 204 | rules: [], 205 | maxDepth: 'asdf', 206 | }, 207 | ].forEach((t: any) => expect(isQueryBuilderConfig(t)).toBeFalsy()); 208 | expect(isQueryBuilderConfig(null)).toBeFalsy(); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "dev/**/*.ts", 35 | "dev/**/*.tsx", 36 | "dev/**/*.vue", 37 | "tests/**/*.ts", 38 | "tests/**/*.tsx" 39 | ], 40 | "exclude": [ 41 | "node_modules" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'vue'; 2 | import { SortableOptions } from 'sortablejs'; 3 | 4 | export interface Rule { 5 | identifier: string, 6 | value: any, 7 | } 8 | 9 | export interface RuleSet { 10 | operatorIdentifier: string, 11 | children: Array, 12 | } 13 | 14 | export interface OperatorDefinition { 15 | identifier: string, 16 | name: string, 17 | } 18 | 19 | export interface RuleDefinition { 20 | identifier: string, 21 | name: string, 22 | component: Component | string, 23 | initialValue?: any, 24 | } 25 | 26 | export interface QueryBuilderConfig { 27 | operators: OperatorDefinition[], 28 | rules: RuleDefinition[], 29 | maxDepth?: number, 30 | colors?: string[], 31 | dragging?: SortableOptions, 32 | } 33 | 34 | export interface GroupOperatorSlotProps { 35 | currentOperator: string, 36 | operators: OperatorDefinition[], 37 | updateCurrentOperator: (newOperator: string) => void, 38 | } 39 | 40 | export interface GroupCtrlSlotProps { 41 | maxDepthExeeded: boolean, 42 | rules: RuleDefinition[], 43 | addRule: (newRule: string) => void, 44 | newGroup: () => void, 45 | } 46 | 47 | export interface RuleSlotProps { 48 | ruleComponent: Component | string, 49 | ruleData: any, 50 | ruleIdentifier: string, 51 | updateRuleData: (newData: any) => void, 52 | } 53 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | css: { 3 | extract: false, 4 | }, 5 | chainWebpack: config => { 6 | const svgRule = config.module.rule('svg'); 7 | svgRule.uses.clear(); 8 | svgRule.use('url-loader') 9 | .loader('url-loader') 10 | .tap(options => ({ 11 | ...options, 12 | limit: 10240, 13 | })); 14 | 15 | config.module 16 | .rule('eslint') 17 | .use('eslint-loader') 18 | .options({ 19 | fix: true, 20 | }); 21 | }, 22 | }; 23 | --------------------------------------------------------------------------------